@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
|
@@ -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';
|
|
@@ -182,6 +183,25 @@ async function start(
|
|
|
182
183
|
await check?.();
|
|
183
184
|
return plainWriteFile(path, content, options as never);
|
|
184
185
|
};
|
|
186
|
+
// `LockingFilesystem.rewriteFile`: read, compute and write with the path
|
|
187
|
+
// "locked". The other writer runs first — where the real filesystem would be
|
|
188
|
+
// acquiring the lock — so `rewrite` reads what that writer left.
|
|
189
|
+
(fs as unknown as Record<string, unknown>).rewriteFile = async (
|
|
190
|
+
path: string,
|
|
191
|
+
rewrite: (current: Buffer | null) => string | Promise<string>,
|
|
192
|
+
) => {
|
|
193
|
+
await runRaceHook();
|
|
194
|
+
// Absent is null; any other read failure is a failure, as in the real one.
|
|
195
|
+
const current = await fs.readFile(path).then(
|
|
196
|
+
(c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c), 'utf8')),
|
|
197
|
+
(err: unknown) => {
|
|
198
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
199
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
|
|
200
|
+
throw err;
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
return plainWriteFile(path, await rewrite(current));
|
|
204
|
+
};
|
|
185
205
|
(fs as unknown as Record<string, unknown>).writeFiles = async (
|
|
186
206
|
writes: { path: string; content: string }[],
|
|
187
207
|
_summary: string,
|
|
@@ -362,6 +382,98 @@ describe('workspace file primitives', () => {
|
|
|
362
382
|
expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
|
|
363
383
|
});
|
|
364
384
|
|
|
385
|
+
it('edit_file writes new_string exactly as sent, `$` patterns included', async () => {
|
|
386
|
+
const base = await start();
|
|
387
|
+
const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: "cost: $& $1 $' $$" });
|
|
388
|
+
expect(res.status).toBe(200);
|
|
389
|
+
expect(String(await fs.readFile(`${KB_DIR}/a.md`))).toBe("hello\ncost: $& $1 $' $$\n");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* An edit is a promise about the text it replaces, so that text has to be
|
|
394
|
+
* found in the file as it is when the write lands — read with the path's
|
|
395
|
+
* lock held, not at a preflight anybody may invalidate. `raceHook` is the
|
|
396
|
+
* other writer, running exactly where the real filesystem acquires the lock.
|
|
397
|
+
*/
|
|
398
|
+
describe('edit_file looks for old_string in the file the write actually lands on', () => {
|
|
399
|
+
const EMPTY_OWNER = '# Assignee\n\n# Log';
|
|
400
|
+
const claim = (base: string, who: string) =>
|
|
401
|
+
post(`${base}/api/agent/tools/edit_file`, {
|
|
402
|
+
path: `${KB_DIR}/ticket.md`,
|
|
403
|
+
old_string: EMPTY_OWNER,
|
|
404
|
+
new_string: `# Assignee\n${who}\n\n# Log`,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('refuses when another writer replaced that text after the preflight, and keeps their write', async () => {
|
|
408
|
+
const base = await start();
|
|
409
|
+
await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
|
|
410
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\ncoder1\n\n# Log\n- filed\n'); };
|
|
411
|
+
const res = await claim(base, 'coder2');
|
|
412
|
+
expect(res.status).toBe(400);
|
|
413
|
+
expect(((await res.json()) as { error: string }).error).toContain('old_string not found');
|
|
414
|
+
expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder1\n\n# Log\n- filed\n');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('applies the edit to what the other writer left when the text is still there', async () => {
|
|
418
|
+
const base = await start();
|
|
419
|
+
await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
|
|
420
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n- a line added meanwhile\n'); };
|
|
421
|
+
const res = await claim(base, 'coder2');
|
|
422
|
+
expect(res.status).toBe(200);
|
|
423
|
+
// Their line is kept: the new content was computed from the file as it was under the lock.
|
|
424
|
+
expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder2\n\n# Log\n- filed\n- a line added meanwhile\n');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('refuses when the text became ambiguous after the preflight', async () => {
|
|
428
|
+
const base = await start();
|
|
429
|
+
await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
|
|
430
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, `${EMPTY_OWNER}\n${EMPTY_OWNER}\n`); };
|
|
431
|
+
const res = await claim(base, 'coder2');
|
|
432
|
+
expect(res.status).toBe(400);
|
|
433
|
+
expect(((await res.json()) as { error: string }).error).toContain('appears 2 times');
|
|
434
|
+
expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe(`${EMPTY_OWNER}\n${EMPTY_OWNER}\n`);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('answers not found when the file was deleted after the preflight, and does not recreate it', async () => {
|
|
438
|
+
const base = await start();
|
|
439
|
+
await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
|
|
440
|
+
raceHook = async () => { await fs.deleteFile(`${KB_DIR}/ticket.md`); };
|
|
441
|
+
const res = await claim(base, 'coder2');
|
|
442
|
+
expect(res.status).toBe(404);
|
|
443
|
+
await expect(fs.readFile(`${KB_DIR}/ticket.md`)).rejects.toThrow();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Every question the tool asks of the file is asked of the bytes it is
|
|
447
|
+
// about to replace. An extensionless file may hold anything, and the app's
|
|
448
|
+
// own upload can swap it for a binary between the preflight and the lock.
|
|
449
|
+
// The binary still CONTAINS `old_string` here, so nothing but the
|
|
450
|
+
// "may these bytes be edited as text" question can refuse it.
|
|
451
|
+
it('refuses when the file became binary after the preflight, and leaves those bytes alone', async () => {
|
|
452
|
+
const base = await start();
|
|
453
|
+
await fs.writeFile(`${KB_DIR}/TICKET`, '# Assignee\n\n# Log\n- filed\n');
|
|
454
|
+
const binary = Buffer.concat([Buffer.from([0x00, 0xff, 0xfe, 0x00]), Buffer.from(`${EMPTY_OWNER}\n`, 'utf8')]);
|
|
455
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/TICKET`, binary); };
|
|
456
|
+
const res = await post(`${base}/api/agent/tools/edit_file`, {
|
|
457
|
+
path: `${KB_DIR}/TICKET`,
|
|
458
|
+
old_string: EMPTY_OWNER,
|
|
459
|
+
new_string: '# Assignee\ncoder2\n\n# Log',
|
|
460
|
+
});
|
|
461
|
+
expect(res.status).toBe(415);
|
|
462
|
+
expect(await res.json()).toMatchObject({ kind: 'binary_not_writable' });
|
|
463
|
+
const after = await fs.readFile(`${KB_DIR}/TICKET`);
|
|
464
|
+
expect(Buffer.from(after as Buffer).equals(binary)).toBe(true);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('counts replace_all over the file as it is under the lock', async () => {
|
|
468
|
+
const base = await start();
|
|
469
|
+
await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x\n');
|
|
470
|
+
raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x x\n'); };
|
|
471
|
+
const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/ticket.md`, old_string: 'x', new_string: 'y', replace_all: true });
|
|
472
|
+
expect(await res.json()).toMatchObject({ replaced: 3 });
|
|
473
|
+
expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('y y y\n');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
365
477
|
it('list_files + file_stat', async () => {
|
|
366
478
|
const base = await start();
|
|
367
479
|
const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
|
|
@@ -920,19 +1032,26 @@ describe('write modes and per-path outcomes', () => {
|
|
|
920
1032
|
});
|
|
921
1033
|
});
|
|
922
1034
|
|
|
923
|
-
it('
|
|
1035
|
+
it('states the three modes on the `mode` input of each write tool, and once in the shared rules', async () => {
|
|
924
1036
|
await start();
|
|
925
1037
|
const tools = await toolRegistry.listInternal();
|
|
1038
|
+
// The paragraph that said this in BOTH descriptions is one shared rule now.
|
|
1039
|
+
// What stays on the tool is the input the agent fills, which is where the
|
|
1040
|
+
// decision is actually taken.
|
|
1041
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'write-mode')!;
|
|
1042
|
+
expect(rule.body).toContain('DEFAULTS TO `create`');
|
|
1043
|
+
expect(rule.body).toContain('On write_file and write_files');
|
|
1044
|
+
expect(sharedFileRulesSection(testKbContext().layout).split(rule.body)).toHaveLength(2);
|
|
926
1045
|
for (const name of ['write_file', 'write_files']) {
|
|
927
1046
|
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
|
-
}
|
|
1047
|
+
expect(def.description, name).not.toContain('DEFAULTS TO `create`');
|
|
932
1048
|
const body = (def.inputs as { properties: { body: { properties: Record<string, { enum?: string[]; description?: string }> } } }).properties.body;
|
|
933
1049
|
expect(body.properties.mode, name).toBeDefined();
|
|
934
1050
|
expect(body.properties.mode.enum, name).toEqual(['create', 'overwrite', 'update']);
|
|
935
1051
|
expect(body.properties.mode.description, name).toContain('default `create`');
|
|
1052
|
+
for (const mode of ['`create`', '`overwrite`', '`update`']) {
|
|
1053
|
+
expect(body.properties.mode.description, `${name} ${mode}`).toContain(mode);
|
|
1054
|
+
}
|
|
936
1055
|
}
|
|
937
1056
|
});
|
|
938
1057
|
|
|
@@ -1595,28 +1714,36 @@ describe('office documents and PDFs', () => {
|
|
|
1595
1714
|
expect(await readContent(base, `${KB_DIR}/deck.pptx`)).toContain('Original');
|
|
1596
1715
|
});
|
|
1597
1716
|
|
|
1598
|
-
it('every
|
|
1717
|
+
it('every mounted tool ends with the one sentence pointing at the shared rules, and repeats none of them', async () => {
|
|
1599
1718
|
await start();
|
|
1600
1719
|
const tools = await toolRegistry.listInternal();
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1720
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
1721
|
+
// The shell is in the list too: it carried the agent-guide reminder before,
|
|
1722
|
+
// and that reminder is one of the rules that moved.
|
|
1723
|
+
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'];
|
|
1724
|
+
for (const name of mounted) {
|
|
1603
1725
|
const def = tools.find((t) => t.name === name);
|
|
1604
1726
|
expect(def, name).toBeDefined();
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
expect(def!.description, name).
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
//
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1727
|
+
expect(def!.description!.endsWith(pointer), name).toBe(true);
|
|
1728
|
+
// Once, at the end — not once per paragraph that used to be appended.
|
|
1729
|
+
expect(def!.description!.split(pointer), name).toHaveLength(2);
|
|
1730
|
+
// EVERY shared rule, in full, is in the two shared places now (see
|
|
1731
|
+
// agent-instructions/__tests__/shared-file-rules.test.ts) and in no
|
|
1732
|
+
// description. Checked on the whole body rather than on a phrase: the
|
|
1733
|
+
// drift this PR exists to prevent is a paragraph pasted back onto a
|
|
1734
|
+
// tool, and naming only two marker phrases would catch two of eight.
|
|
1735
|
+
for (const rule of sharedFileRules(testKbContext().layout)) {
|
|
1736
|
+
expect(def!.description, `${name} / ${rule.id}`).not.toContain(rule.body);
|
|
1737
|
+
}
|
|
1738
|
+
// The two lead-in labels the paragraphs used to arrive under are gone
|
|
1739
|
+
// with them — a description carrying one is carrying the old text.
|
|
1740
|
+
expect(def!.description, name).not.toContain('Content rule (the same on every file tool)');
|
|
1741
|
+
expect(def!.description, name).not.toContain('Before your first read or change in a workspace');
|
|
1617
1742
|
}
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1743
|
+
// start_session carried none of the shared paragraphs and gains no pointer:
|
|
1744
|
+
// it touches no file. External-only, so it is looked up on that surface.
|
|
1745
|
+
const external = await toolRegistry.listExternal();
|
|
1746
|
+
expect(external.find((t) => t.name === 'start_session')!.description).not.toContain(pointer);
|
|
1620
1747
|
});
|
|
1621
1748
|
|
|
1622
1749
|
describe('binary capability contract: a text file, a document, an image and a zip', () => {
|
|
@@ -1762,14 +1889,15 @@ describe('office documents and PDFs', () => {
|
|
|
1762
1889
|
});
|
|
1763
1890
|
});
|
|
1764
1891
|
|
|
1765
|
-
it('the
|
|
1892
|
+
it('says where the images a page uses go — once, in the shared rules', async () => {
|
|
1766
1893
|
await start();
|
|
1767
1894
|
const tools = await toolRegistry.listInternal();
|
|
1895
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'images-in-pages')!;
|
|
1896
|
+
expect(rule.body).toContain('`assets/` folder next to the page');
|
|
1897
|
+
expect(rule.body).toContain('');
|
|
1898
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
1768
1899
|
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('');
|
|
1900
|
+
expect(tools.find((t) => t.name === name)!.description, name).not.toContain('`assets/` folder next to the page');
|
|
1773
1901
|
}
|
|
1774
1902
|
});
|
|
1775
1903
|
});
|
|
@@ -3313,8 +3441,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3313
3441
|
expect(d.description).toMatch(/FILE or FOLDER/);
|
|
3314
3442
|
expect(d.description).toMatch(/folder moves recursively/);
|
|
3315
3443
|
expect(d.description).toMatch(/destination must not exist/);
|
|
3316
|
-
|
|
3317
|
-
|
|
3444
|
+
// What refuses a move, and the dry-run/confirm protocol it shares with the
|
|
3445
|
+
// deletes, are shared rules — stated once, in the two shared places.
|
|
3446
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3447
|
+
expect(rules).toContain('is a platform file and stays in its folder.');
|
|
3448
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3449
|
+
expect(rules).toContain('`confirm: true`');
|
|
3318
3450
|
expect(d.description).toContain('`confirm: true`');
|
|
3319
3451
|
expect(Object.keys(d.inputs.properties.body.properties)).toEqual(expect.arrayContaining(['dryRun', 'confirm']));
|
|
3320
3452
|
// What the description promises a dry run returns is what it returns.
|
|
@@ -3324,7 +3456,10 @@ describe('preflight for moves and deletes', () => {
|
|
|
3324
3456
|
expect(dry.body, key).toHaveProperty(key);
|
|
3325
3457
|
}
|
|
3326
3458
|
const unconfirmed = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') });
|
|
3327
|
-
|
|
3459
|
+
// What an unconfirmed call answers is the shared protocol's promise; the
|
|
3460
|
+
// field is still declared in this tool's own `outputs`.
|
|
3461
|
+
expect(rules).toContain('confirmationRequired: true');
|
|
3462
|
+
expect(declaredOutputs(d)).toContain('confirmationRequired');
|
|
3328
3463
|
expect(unconfirmed.body.confirmationRequired).toBe(true);
|
|
3329
3464
|
for (const body of [dry.body, unconfirmed.body]) {
|
|
3330
3465
|
expect(declaredOutputs(d)).toEqual(expect.arrayContaining(Object.keys(body)));
|
|
@@ -3334,9 +3469,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3334
3469
|
it('delete_folder states the confirm rule and refusals, and declares every field it returns', async () => {
|
|
3335
3470
|
const base = await seeded();
|
|
3336
3471
|
const d = await def('delete_folder');
|
|
3337
|
-
expect(d.description).toContain('`
|
|
3338
|
-
|
|
3339
|
-
|
|
3472
|
+
expect(d.description).toContain('a non-empty folder wants `confirm: true`');
|
|
3473
|
+
// The protocol itself, and what a platform folder does to it, are shared.
|
|
3474
|
+
const rules = sharedFileRulesSection(testKbContext().layout);
|
|
3475
|
+
expect(rules).toContain('`dryRun: true`');
|
|
3476
|
+
expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
|
|
3477
|
+
expect(rules).toContain('platform folder');
|
|
3340
3478
|
const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
|
|
3341
3479
|
const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
|
|
3342
3480
|
const confirmed = await call(base, 'delete_folder', { path: KB('Sales/archive'), confirm: true });
|
|
@@ -3354,8 +3492,12 @@ describe('preflight for moves and deletes', () => {
|
|
|
3354
3492
|
expect(stat.description, key).toContain(`\`${key}`);
|
|
3355
3493
|
expect(body, key).toHaveProperty(key);
|
|
3356
3494
|
}
|
|
3495
|
+
expect(stat.description).toContain('shared rules on what these tools never move or delete');
|
|
3496
|
+
// The proposal route applies to every tool a permission can refuse, so it
|
|
3497
|
+
// is stated once in the shared rules rather than on each of them.
|
|
3498
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain('`write-denied`');
|
|
3357
3499
|
for (const name of ['move_file', 'delete_file', 'delete_folder']) {
|
|
3358
|
-
expect((await def(name)).description, name).toContain(
|
|
3500
|
+
expect((await def(name)).description, name).toContain(sharedRulesPointer(testKbContext().layout));
|
|
3359
3501
|
}
|
|
3360
3502
|
});
|
|
3361
3503
|
});
|
|
@@ -3414,7 +3556,7 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3414
3556
|
throw err;
|
|
3415
3557
|
};
|
|
3416
3558
|
const target = fs as unknown as Record<string, unknown>;
|
|
3417
|
-
for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
|
|
3559
|
+
for (const m of ['writeFile', 'rewriteFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
|
|
3418
3560
|
target.copyFile = async (_src: string, dest: string) => refuse(dest);
|
|
3419
3561
|
// `delete_folder` lands through the same batch with NO writes and the
|
|
3420
3562
|
// folder's files as `deletes` — in production the refusal comes from the
|
|
@@ -3602,14 +3744,18 @@ describe('a write refused for permissions says whether and how to propose it', (
|
|
|
3602
3744
|
expect(res.json).toEqual({ error: 'old_string not found in the file.' });
|
|
3603
3745
|
});
|
|
3604
3746
|
|
|
3605
|
-
it('
|
|
3747
|
+
it('states the proposal route in the shared rules, not in each write tool description', async () => {
|
|
3606
3748
|
await start();
|
|
3607
3749
|
const tools = await toolRegistry.listInternal();
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3750
|
+
// It applies to every tool a permission can refuse, so it is a shared rule:
|
|
3751
|
+
// stated in the handshake instructions and in the managed guide, and in no
|
|
3752
|
+
// description. The refusal ITSELF still spells the steps out — that is what
|
|
3753
|
+
// the tests above this one assert.
|
|
3754
|
+
const rule = sharedFileRules(testKbContext().layout).find((r) => r.id === 'refused-for-permissions')!;
|
|
3755
|
+
expect(sharedFileRulesSection(testKbContext().layout)).toContain(rule.body);
|
|
3756
|
+
for (const def of tools) {
|
|
3757
|
+
expect(def.description ?? '', def.name).not.toContain('If this is refused for permissions');
|
|
3758
|
+
expect(def.description ?? '', def.name).not.toContain(rule.body);
|
|
3613
3759
|
}
|
|
3614
3760
|
});
|
|
3615
3761
|
});
|
|
@@ -4129,18 +4275,22 @@ describe('tool descriptions and the deployment note', () => {
|
|
|
4129
4275
|
expect(all.get('read_file')!.description).not.toContain('Stay within one');
|
|
4130
4276
|
});
|
|
4131
4277
|
|
|
4132
|
-
it(
|
|
4278
|
+
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
4279
|
await start();
|
|
4134
4280
|
notes.registerGatedToolNote(' One folder per conversation.');
|
|
4135
4281
|
notes.registerSessionIdNote(' It also pins that folder.');
|
|
4136
4282
|
const all = await defs();
|
|
4283
|
+
// The POINTER is last, always: that is the one sentence an agent needs to
|
|
4284
|
+
// find the shared rules, and a description cut short must not lose it. The
|
|
4285
|
+
// deployment's note sits directly before it, after the tool's own text.
|
|
4286
|
+
const pointer = sharedRulesPointer(testKbContext().layout);
|
|
4137
4287
|
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(
|
|
4288
|
+
expect(all.get(name)!.description.endsWith(` One folder per conversation.${pointer}`), name).toBe(true);
|
|
4139
4289
|
expect(sessionIdDescriptionOf(all.get(name)!), name).toBe(`${SESSION_ID_DESCRIPTION} It also pins that folder.`);
|
|
4140
4290
|
}
|
|
4141
4291
|
// `execute_command` is internal-only, so it is checked on that surface.
|
|
4142
4292
|
const internal = new Map((await toolRegistry.listInternal()).map((t) => [t.name, t]));
|
|
4143
|
-
expect(internal.get('execute_command')!.description.endsWith(
|
|
4293
|
+
expect(internal.get('execute_command')!.description.endsWith(` One folder per conversation.${pointer}`)).toBe(true);
|
|
4144
4294
|
});
|
|
4145
4295
|
|
|
4146
4296
|
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
|
+
}
|