@bevel-software/platform-core-backend 0.23.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/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 +10 -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 +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/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.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 +512 -113
- 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 +2 -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 +18 -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 +561 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +81 -31
- package/src/modules/database/migrate.ts +353 -1
- 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/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 +84 -45
- 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 +588 -127
- 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
|
@@ -13,7 +13,8 @@ import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
|
13
13
|
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
14
14
|
import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
|
|
15
15
|
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
16
|
-
import {
|
|
16
|
+
import { registerWorkspaceTools } from '../workspace.tools.js';
|
|
17
|
+
import { sharedFileRules, sharedFileRulesSection, sharedRulesPointer } from '../../agent-instructions/shared-file-rules.js';
|
|
17
18
|
import { RoutineWritePolicyService } from '../routine-write-policy.js';
|
|
18
19
|
import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
|
|
19
20
|
import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
|
|
@@ -30,7 +31,7 @@ import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
|
30
31
|
import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
|
|
31
32
|
import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
|
|
32
33
|
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
33
|
-
import {
|
|
34
|
+
import { proposalTitleFor } from '../write-denial.js';
|
|
34
35
|
import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
|
|
35
36
|
|
|
36
37
|
const KB_DIR = 'knowledge-base';
|
|
@@ -920,19 +921,26 @@ describe('write modes and per-path outcomes', () => {
|
|
|
920
921
|
});
|
|
921
922
|
});
|
|
922
923
|
|
|
923
|
-
it('
|
|
924
|
+
it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
|
|
924
925
|
await start();
|
|
925
926
|
const tools = await toolRegistry.listInternal();
|
|
927
|
+
// The paragraph that said this in BOTH descriptions is one shared rule now.
|
|
928
|
+
// What stays on the tool is the input the agent fills, which is where the
|
|
929
|
+
// decision is actually taken.
|
|
930
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
|
|
931
|
+
expect(rule.body).toContain('DEFAULTS TO `create`');
|
|
932
|
+
expect(rule.body).toContain('On write_file and write_files');
|
|
933
|
+
expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
|
|
926
934
|
for (const name of ['write_file', 'write_files']) {
|
|
927
935
|
const def = tools.find((t) => t.name === name)!;
|
|
928
|
-
expect(def.description, name).toContain('DEFAULTS TO `create`');
|
|
929
|
-
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
930
|
-
expect(def.description, `${name} ${mode}`).toContain(mode);
|
|
931
|
-
}
|
|
936
|
+
expect(def.description, name).not.toContain('DEFAULTS TO `create`');
|
|
932
937
|
const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
|
|
933
938
|
expect(body.properties.mode, name).toBeDefined();
|
|
934
939
|
expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
|
|
935
940
|
expect(body.properties.mode.description, name).toContain('default `create`');
|
|
941
|
+
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
942
|
+
expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
|
|
943
|
+
}
|
|
936
944
|
}
|
|
937
945
|
});
|
|
938
946
|
|
|
@@ -1595,28 +1603,36 @@ describe('office documents and PDFs', () => {
|
|
|
1595
1603
|
expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
|
|
1596
1604
|
});
|
|
1597
1605
|
|
|
1598
|
-
it('every
|
|
1606
|
+
it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
|
|
1599
1607
|
await start();
|
|
1600
1608
|
const tools = await toolRegistry.listInternal();
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1609
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
1610
|
+
// The shell is in the list too: it carried the agent-guide reminder before,
|
|
1611
|
+
// and that reminder is one of the rules that moved.
|
|
1612
|
+
const mounted = ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip', 'execute_command'];
|
|
1613
|
+
for (const name of mounted) {
|
|
1603
1614
|
const def = tools.find((t) => t.name === name);
|
|
1604
1615
|
expect(def, name).toBeDefined();
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
expect(def!.description, name).
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
//
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1616
|
+
expect(def!.description!.endsWith(pointer), name).toBe(true);
|
|
1617
|
+
// Once, at the end — not once per paragraph that used to be appended.
|
|
1618
|
+
expect(def!.description!.split(pointer), name).toHaveLength(2);
|
|
1619
|
+
// EVERY shared rule, in full, is in the two shared places now (see
|
|
1620
|
+
// agent-instructions/__tests__/shared-file-rules.test.ts) and in no
|
|
1621
|
+
// description. Checked on the whole body rather than on a phrase: the
|
|
1622
|
+
// drift this PR exists to prevent is a paragraph pasted back onto a
|
|
1623
|
+
// tool, and naming only two marker phrases would catch two of eight.
|
|
1624
|
+
for (const rule of sharedFileRules(testKbContext().layout)) {
|
|
1625
|
+
expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
|
|
1626
|
+
}
|
|
1627
|
+
// The two lead-in labels the paragraphs used to arrive under are gone
|
|
1628
|
+
// with them — a description carrying one is carrying the old text.
|
|
1629
|
+
expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
|
|
1630
|
+
expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
|
|
1617
1631
|
}
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1632
|
+
// start_session carried none of the shared paragraphs and gains no pointer:
|
|
1633
|
+
// it touches no file. External-only, so it is looked up on that surface.
|
|
1634
|
+
const external = await toolRegistry.listExternal();
|
|
1635
|
+
expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
|
|
1620
1636
|
});
|
|
1621
1637
|
|
|
1622
1638
|
describe('binary capability contract: a text file, a document, an image and a zip', () => {
|
|
@@ -1762,14 +1778,15 @@ describe('office documents and PDFs', () => {
|
|
|
1762
1778
|
});
|
|
1763
1779
|
});
|
|
1764
1780
|
|
|
1765
|
-
it('the
|
|
1781
|
+
it('says where the images a page uses go — once, in the shared rules', async () => {
|
|
1766
1782
|
await start();
|
|
1767
1783
|
const tools = await toolRegistry.listInternal();
|
|
1784
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
|
|
1785
|
+
expect(rule.body).toContain('`assets/` folder next to the page');
|
|
1786
|
+
expect(rule.body).toContain('');
|
|
1787
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
1768
1788
|
for (const name of ['write_file', 'write_files']) {
|
|
1769
|
-
|
|
1770
|
-
expect(def, name).toBeDefined();
|
|
1771
|
-
expect(def!.description, name).toContain('`assets/` folder next to the page');
|
|
1772
|
-
expect(def!.description, name).toContain('');
|
|
1789
|
+
expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
|
|
1773
1790
|
}
|
|
1774
1791
|
});
|
|
1775
1792
|
});
|
|
@@ -3313,8 +3330,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3313
3330
|
expect(d.description).toMatch(/FILE or FOLDER/);
|
|
3314
3331
|
expect(d.description).toMatch(/folder moves recursively/);
|
|
3315
3332
|
expect(d.description).toMatch(/destination must not exist/);
|
|
3316
|
-
|
|
3317
|
-
|
|
3333
|
+
// What refuses a move, and the dry-run/confirm protocol it shares with the
|
|
3334
|
+
// deletes, are shared rules — stated once, in the two shared places.
|
|
3335
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3336
|
+
expect(rules).toContain('is a platform file and stays in its folder.');
|
|
3337
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3338
|
+
expect(rules).toContain('`confirm: true`');
|
|
3318
3339
|
expect(d.description).toContain('`confirm: true`');
|
|
3319
3340
|
expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
|
|
3320
3341
|
// What the description promises a dry run returns is what it returns.
|
|
@@ -3324,7 +3345,10 @@ describe('preflight for moves and deletes', () => {
|
|
|
3324
3345
|
expect(dry.body, key).toHaveProperty(key);
|
|
3325
3346
|
}
|
|
3326
3347
|
const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
|
|
3327
|
-
|
|
3348
|
+
// What an unconfirmed call answers is the shared protocol's promise; the
|
|
3349
|
+
// field is still declared in this tool's own `outputs`.
|
|
3350
|
+
expect(rules).toContain('confirmationRequired: true');
|
|
3351
|
+
expect(declaredOutputs(d)).toContain('confirmationRequired');
|
|
3328
3352
|
expect(unconfirmed.body.confirmationRequired).toBe(true);
|
|
3329
3353
|
for (const body of [dry.body, unconfirmed.body]) {
|
|
3330
3354
|
expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
|
|
@@ -3334,9 +3358,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3334
3358
|
it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
|
|
3335
3359
|
const base = await seeded();
|
|
3336
3360
|
const d = await def('delete_folder');
|
|
3337
|
-
expect(d.description).toContain('`
|
|
3338
|
-
|
|
3339
|
-
|
|
3361
|
+
expect(d.description).toContain('a non-empty folder wants `confirm: true`');
|
|
3362
|
+
// The protocol itself, and what a platform folder does to it, are shared.
|
|
3363
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3364
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3365
|
+
expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
|
|
3366
|
+
expect(rules).toContain('platform folder');
|
|
3340
3367
|
const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
|
|
3341
3368
|
const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
|
|
3342
3369
|
const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
|
|
@@ -3354,8 +3381,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3354
3381
|
expect(stat.description, key).toContain(`\`${key}`);
|
|
3355
3382
|
expect(body, key).toHaveProperty(key);
|
|
3356
3383
|
}
|
|
3384
|
+
expect(stat.description).toContain('shared rules on what these tools never move or delete');
|
|
3385
|
+
// The proposal route applies to every tool a permission can refuse, so it
|
|
3386
|
+
// is stated once in the shared rules rather than on each of them.
|
|
3387
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
|
|
3357
3388
|
for (const name of ['move_file', 'delete_file', 'delete_folder']) {
|
|
3358
|
-
expect((await def(name)).description, name).toContain(
|
|
3389
|
+
expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
|
|
3359
3390
|
}
|
|
3360
3391
|
});
|
|
3361
3392
|
});
|
|
@@ -3602,14 +3633,18 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3602
3633
|
expect(res.json).toEqual({ error: 'old_string not found in the file.' });
|
|
3603
3634
|
});
|
|
3604
3635
|
|
|
3605
|
-
it('
|
|
3636
|
+
it('states the proposal route in the shared rules, not in each write tool description', async () => {
|
|
3606
3637
|
await start();
|
|
3607
3638
|
const tools = await toolRegistry.listInternal();
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3639
|
+
// It applies to every tool a permission can refuse, so it is a shared rule:
|
|
3640
|
+
// stated in the handshake instructions and in the managed guide, and in no
|
|
3641
|
+
// description. The refusal ITSELF still spells the steps out — that is what
|
|
3642
|
+
// the tests above this one assert.
|
|
3643
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
|
|
3644
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
3645
|
+
for (const def of tools) {
|
|
3646
|
+
expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
|
|
3647
|
+
expect(def.description ?? '', def.name).not.toContain(rule.body);
|
|
3613
3648
|
}
|
|
3614
3649
|
});
|
|
3615
3650
|
});
|
|
@@ -4129,18 +4164,22 @@ describe('tool descriptions and the deployment note', () => {
|
|
|
4129
4164
|
expect(all.get('read_file')!.description).not.toContain('Stay within one');
|
|
4130
4165
|
});
|
|
4131
4166
|
|
|
4132
|
-
it(
|
|
4167
|
+
it("a registered note lands after every gated tool's own text, ahead of the shared-rules pointer, and on the sessionId input", async () => {
|
|
4133
4168
|
await start();
|
|
4134
4169
|
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4135
4170
|
notes.registerSessionIdNote(' It also pins that folder.');
|
|
4136
4171
|
const all = await defs();
|
|
4172
|
+
// The POINTER is last, always: that is the one sentence an agent needs to
|
|
4173
|
+
// find the shared rules, and a description cut short must not lose it. The
|
|
4174
|
+
// deployment's note sits directly before it, after the tool's own text.
|
|
4175
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
4137
4176
|
for (const name of ['read_file', 'list_files', 'file_stat', 'grep', 'write_file', 'write_files', 'edit_file', 'delete_file', 'delete_folder', 'mkdir', 'move_file', 'copy_file', 'unzip']) {
|
|
4138
|
-
expect(all.get(name)!.description.endsWith(
|
|
4177
|
+
expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
|
|
4139
4178
|
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4140
4179
|
}
|
|
4141
4180
|
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4142
4181
|
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4143
|
-
expect(internal.get('execute_command')!.description.endsWith(
|
|
4182
|
+
expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
|
|
4144
4183
|
});
|
|
4145
4184
|
|
|
4146
4185
|
it('a tool that is not gated carries no note', async () => {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { validateFilename } from '@bevel-software/platform-shared';
|
|
3
|
+
import { logger } from '../../shared/logging.js';
|
|
4
|
+
import { AgentUploadStore, UploadTokenError, overLimit } from './agent-upload.store.js';
|
|
5
|
+
|
|
6
|
+
const log = logger('agent-uploads');
|
|
7
|
+
|
|
8
|
+
/** The route's own prefix, under `/api`. One spelling, shared with the raw-body test below. */
|
|
9
|
+
export const AGENT_UPLOAD_ROUTE = '/agent/uploads/:token';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same endpoint with the token in the `x-upload-token` HEADER instead of
|
|
13
|
+
* the path — the address `uploadUrl` is, without its last segment.
|
|
14
|
+
*
|
|
15
|
+
* Two spellings because a token in a URL is a credential in a place that keeps
|
|
16
|
+
* copies: an access log, a proxy log, a shell history, a `ps` listing of the
|
|
17
|
+
* `curl` that sent it. The path form is what `request_file_upload` answers,
|
|
18
|
+
* because one address an agent can paste into any client is the thing that
|
|
19
|
+
* makes this route usable at all; the header form is for a caller that would
|
|
20
|
+
* rather its credential not be written down on the way. Both reach the same
|
|
21
|
+
* handler and are judged identically — same token, same single use.
|
|
22
|
+
*/
|
|
23
|
+
export const AGENT_UPLOAD_HEADER_ROUTE = '/agent/uploads';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether `path` is the agent upload route, and so must reach its handler with
|
|
27
|
+
* the body still a STREAM.
|
|
28
|
+
*
|
|
29
|
+
* The app installs a global `express.json()`. It only claims a JSON
|
|
30
|
+
* content-type, but `curl --data-binary @file.zip -H 'content-type:
|
|
31
|
+
* application/json'` is a request an agent can and will make — and once the
|
|
32
|
+
* parser has drained the stream there are no bytes left to store, so the
|
|
33
|
+
* upload would answer "0 bytes received" for a file that was sent in full.
|
|
34
|
+
* Exempting the path is the same move `/api/sync` makes for its HMAC body, and
|
|
35
|
+
* for the same reason: whoever needs the exact bytes has to see them first.
|
|
36
|
+
*
|
|
37
|
+
* Case-insensitive, because Express routing is: `/api/Agent/uploads/x` reaches
|
|
38
|
+
* this router, and a check that said no would let the parser eat that body.
|
|
39
|
+
*/
|
|
40
|
+
export function isAgentUploadRawBodyPath(path: string): boolean {
|
|
41
|
+
// Trailing slashes trimmed first, so the header form's bare address matches
|
|
42
|
+
// in every spelling Express routes to it (`/api/agent/uploads` and
|
|
43
|
+
// `/api/agent/uploads/` are one route): missing one of them would hand that
|
|
44
|
+
// request to the JSON parser, and the bytes it drains are gone.
|
|
45
|
+
const lower = path.toLowerCase().replace(/\/+$/, '');
|
|
46
|
+
return lower === '/api/agent/uploads' || lower.startsWith('/api/agent/uploads/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AgentUploadRouteDeps {
|
|
50
|
+
uploads: AgentUploadStore;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `POST /api/agent/uploads/:token` — the one endpoint on this server
|
|
55
|
+
* authenticated by a single-use token and nothing else.
|
|
56
|
+
*
|
|
57
|
+
* It exists because MCP tool arguments are JSON, so every byte an agent sends
|
|
58
|
+
* through a tool passes through the model first. A 37 KB page gets truncated
|
|
59
|
+
* mid-response; a file of regex backslashes fails to parse as a JSON string; a
|
|
60
|
+
* PNG cannot be sent at all. Here the agent asks for a token, sends the file
|
|
61
|
+
* with any HTTP client it has, and then names the token in
|
|
62
|
+
* `apply_file_upload`. The bytes never enter a prompt.
|
|
63
|
+
*
|
|
64
|
+
* What the route itself may do is deliberately almost nothing: it stores bytes
|
|
65
|
+
* against a token, in a directory beside the workspaces root, and answers what
|
|
66
|
+
* it received. It resolves no workspace, writes nothing into one, and commits
|
|
67
|
+
* nothing — every access, platform-file and branch rule is applied later, by
|
|
68
|
+
* the apply tool, against the branch the caller then names. A token that is
|
|
69
|
+
* unknown, spent, expired or someone else's gets one 404 that says which of
|
|
70
|
+
* those it was: none of them.
|
|
71
|
+
*/
|
|
72
|
+
export function createAgentUploadRoutes(deps: AgentUploadRouteDeps): express.Router {
|
|
73
|
+
const router = express.Router();
|
|
74
|
+
const { uploads } = deps;
|
|
75
|
+
|
|
76
|
+
const handle: express.RequestHandler = async (req, res) => {
|
|
77
|
+
const token = tokenOf(req);
|
|
78
|
+
try {
|
|
79
|
+
// THE TOKEN FIRST, before anything about the request is read, parsed,
|
|
80
|
+
// judged or quoted back. This route is authenticated by the token and
|
|
81
|
+
// nothing else, which cuts two ways. Without the check up here, anyone
|
|
82
|
+
// could make the process buffer the deployment's whole upload limit per
|
|
83
|
+
// request against tokens they invented, as many at a time as they liked;
|
|
84
|
+
// and a caller holding no token could learn which of its OTHER guesses
|
|
85
|
+
// were well formed — "that is not a usable file name" is an answer only
|
|
86
|
+
// somebody entitled to send a file should get. One refusal, nothing else.
|
|
87
|
+
// `receive` below asks again, under the same record, because that is
|
|
88
|
+
// where the token is actually spent.
|
|
89
|
+
uploads.assertOpen(token);
|
|
90
|
+
const filename = fileNameOf(req);
|
|
91
|
+
if (filename === null) {
|
|
92
|
+
res.status(400).json({
|
|
93
|
+
error:
|
|
94
|
+
'Name the file you are sending: add `?filename=<name>` to the upload URL (or send it as the ' +
|
|
95
|
+
'`x-upload-filename` header). A single file lands under that name, and a name ending in `.zip` is ' +
|
|
96
|
+
'read as an archive.',
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const invalid = validateFilename(filename);
|
|
101
|
+
if (invalid !== null || filename.includes('/')) {
|
|
102
|
+
res.status(400).json({
|
|
103
|
+
error: `"${filename}" is not a usable file name: ${invalid ?? 'a name cannot contain "/"'}. Send one plain file name.`,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Then the declared length, so a caller sending something far too large
|
|
108
|
+
// is told the limit before it spends the bandwidth. The real total is
|
|
109
|
+
// counted by the store as the bytes arrive — `content-length` is the
|
|
110
|
+
// sender's claim, not a fact.
|
|
111
|
+
const declared = Number.parseInt(req.headers['content-length'] ?? '', 10);
|
|
112
|
+
if (Number.isFinite(declared) && declared > uploads.maxBytes) {
|
|
113
|
+
res.status(413).json({ error: overLimit(declared, uploads.maxBytes) });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// The request itself, as a stream: the store writes the bytes to disk as
|
|
117
|
+
// they arrive and never holds the body. It refuses an empty body, one
|
|
118
|
+
// past the limit and an unreadable archive with its own status.
|
|
119
|
+
res.json(await uploads.receive(token, filename, req));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err instanceof UploadTokenError) {
|
|
122
|
+
res.status(err.status).json({ error: err.message });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// The detail stays in the log. An unexpected failure here is a
|
|
126
|
+
// filesystem error, and its message quotes the absolute path of the
|
|
127
|
+
// store's root — a place the caller is told nothing else about.
|
|
128
|
+
log.error('upload failed:', { err });
|
|
129
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Both spellings of the one endpoint: the token in the path, or in the
|
|
134
|
+
// `x-upload-token` header on the bare address.
|
|
135
|
+
router.post(AGENT_UPLOAD_ROUTE, handle);
|
|
136
|
+
router.post(AGENT_UPLOAD_HEADER_ROUTE, handle);
|
|
137
|
+
|
|
138
|
+
return router;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The token the sender presented: the `:token` path segment, or the
|
|
143
|
+
* `x-upload-token` header when the bytes went to the bare address.
|
|
144
|
+
*
|
|
145
|
+
* The path wins when both are present — it is the address the sender actually
|
|
146
|
+
* POSTed to, and a header left over from an earlier upload must not quietly
|
|
147
|
+
* redirect these bytes onto a different token.
|
|
148
|
+
*
|
|
149
|
+
* No token at all answers the empty string rather than its own refusal, so it
|
|
150
|
+
* goes through `assertOpen` like any other unusable token and gets the same
|
|
151
|
+
* single 404. A caller holding nothing is told nothing it did not already
|
|
152
|
+
* know — not even whether this endpoint wanted a header.
|
|
153
|
+
*/
|
|
154
|
+
function tokenOf(req: express.Request): string {
|
|
155
|
+
const inPath = req.params.token;
|
|
156
|
+
if (typeof inPath === 'string' && inPath.trim() !== '') return inPath.trim();
|
|
157
|
+
const header = req.headers['x-upload-token'];
|
|
158
|
+
if (typeof header === 'string' && header.trim() !== '') return header.trim();
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The name the sender gave the file: the `filename` query parameter, the
|
|
164
|
+
* `x-upload-filename` header, or a `content-disposition`'s own `filename=`.
|
|
165
|
+
* Three spellings because three kinds of client are expected to use this —
|
|
166
|
+
* `curl` with a query string, a scripted `fetch` with a header, and a client
|
|
167
|
+
* that sends the disposition it would send to any upload endpoint.
|
|
168
|
+
*/
|
|
169
|
+
function fileNameOf(req: express.Request): string | null {
|
|
170
|
+
const query = req.query.filename;
|
|
171
|
+
if (typeof query === 'string' && query.trim() !== '') return query.trim();
|
|
172
|
+
const header = req.headers['x-upload-filename'];
|
|
173
|
+
if (typeof header === 'string' && header.trim() !== '') return header.trim();
|
|
174
|
+
const disposition = req.headers['content-disposition'];
|
|
175
|
+
if (typeof disposition === 'string') return dispositionFilename(disposition);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `filename` a `content-disposition` names, or null when it names none.
|
|
181
|
+
*
|
|
182
|
+
* A QUOTED value is read to its closing quote, not to the first semicolon: a
|
|
183
|
+
* semicolon separates the header's parameters only OUTSIDE the quotes, and
|
|
184
|
+
* `filename="report;final.md"` is one perfectly ordinary name that a
|
|
185
|
+
* semicolon-first reading landed as `report`. An unquoted value ends at the
|
|
186
|
+
* next parameter, as it must.
|
|
187
|
+
*
|
|
188
|
+
* Percent-decoded only in the extended `filename*=UTF-8''…` form, which is the
|
|
189
|
+
* only one where the encoding is part of the grammar. A plain `filename=` value
|
|
190
|
+
* is the name itself, so `50%20off.md` stays `50%20off.md` rather than losing
|
|
191
|
+
* its `%20` to a decode nobody asked for.
|
|
192
|
+
*
|
|
193
|
+
* The parameter name is matched at a boundary — the start of the header or a
|
|
194
|
+
* `;` — because `filename` is a suffix of other perfectly legal parameter
|
|
195
|
+
* names: without it, `inline; xfilename=wrong.md` read `wrong.md` as the name
|
|
196
|
+
* the sender gave, from a parameter that says nothing of the kind.
|
|
197
|
+
*/
|
|
198
|
+
export function dispositionFilename(disposition: string): string | null {
|
|
199
|
+
const match = /(?:^|;)\s*filename(\*?)\s*=\s*(?:"([^"]*)"|([^;]*))/i.exec(disposition);
|
|
200
|
+
if (!match) return null;
|
|
201
|
+
const extended = match[1] === '*';
|
|
202
|
+
const raw = (match[2] ?? match[3] ?? '').trim();
|
|
203
|
+
if (!extended) return raw === '' ? null : raw;
|
|
204
|
+
// `UTF-8''name`, or any other charset and language the sender declares.
|
|
205
|
+
const encoded = raw.replace(/^[^']*'[^']*'/, '');
|
|
206
|
+
try {
|
|
207
|
+
const decoded = decodeURIComponent(encoded).trim();
|
|
208
|
+
if (decoded !== '') return decoded;
|
|
209
|
+
} catch {
|
|
210
|
+
const kept = encoded.trim();
|
|
211
|
+
if (kept !== '') return kept;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|