@bevel-software/platform-core-backend 0.7.4 → 0.8.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/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +51 -7
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +5 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +38 -21
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.js +19 -19
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +18 -4
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +14 -4
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- package/dist/modules/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.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 +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +11 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +37 -9
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +63 -9
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +6 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +26 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +83 -12
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +106 -106
- package/src/core/create-core-server.ts +55 -9
- package/src/core/create-core-services.ts +48 -24
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +20 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/roles-admin.service.ts +13 -2
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +118 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/pending-commits.service.ts +48 -12
- package/src/modules/workflow/pending-commits.worker.ts +64 -8
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workflow/workflow.service.ts +13 -2
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/kb-seed.service.ts +38 -9
- package/src/modules/workspace/plugins-migration.ts +479 -0
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +85 -14
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import express, { type Request, type RequestHandler } from 'express';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import AdmZip from 'adm-zip';
|
|
5
|
+
import { DEFAULT_BRANCH, PLUGINS_DIR } from '@bevel-software/platform-shared';
|
|
6
|
+
import { workspaceIdForBranch, type WorkspaceService } from '../workspace/workspace.service.js';
|
|
7
|
+
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
2
8
|
import '@utcp/http'; // side effect: register the 'http' call-template type
|
|
3
9
|
import { CallTemplateSerializer, type CallTemplate } from '@utcp/sdk';
|
|
4
10
|
import { type IToolManualService, EXTERNAL_KB_MANUAL_NAME } from './tool-manuals.contract.js';
|
|
11
|
+
import { McpServerEditError, type McpServerEditService, type McpServerWrite } from './mcp-server-edit.service.js';
|
|
12
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
5
13
|
import '../auth/auth.middleware.js'; // Express Request augmentation (req.userId / req.userEmail)
|
|
6
14
|
import '../tool-auth/tool-auth.middleware.js'; // Express Request augmentation (req.toolAuth)
|
|
7
15
|
|
|
@@ -35,6 +43,7 @@ export function createToolManualsAgentRoutes(
|
|
|
35
43
|
toolManualService: IToolManualService,
|
|
36
44
|
manualAuth: RequestHandler,
|
|
37
45
|
resolveUserEmail: ResolveUserEmail,
|
|
46
|
+
archiveDeps?: { workspaceService: WorkspaceService; accessControl: IAccessControl; kbDirName: string },
|
|
38
47
|
): express.Router {
|
|
39
48
|
const router = express.Router();
|
|
40
49
|
|
|
@@ -63,6 +72,173 @@ export function createToolManualsAgentRoutes(
|
|
|
63
72
|
}
|
|
64
73
|
});
|
|
65
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The whole plugin, byte-for-byte, as a zip — the materialization surface
|
|
77
|
+
* for the local MCP server. `read_file` is the agent's READING tool (text,
|
|
78
|
+
* one file at a time); this exists because a stdio server's plugin must
|
|
79
|
+
* land on the user's disk exactly as it is, binaries included. Access is
|
|
80
|
+
* per-file and the caller's own: every entry is filtered through the same
|
|
81
|
+
* read verdicts `list_files` uses, so a file the key cannot read is a file
|
|
82
|
+
* that is not in the archive — not an error, an absence.
|
|
83
|
+
*/
|
|
84
|
+
router.get('/agent/plugins/:folder/archive', manualAuth, async (req, res) => {
|
|
85
|
+
if (!archiveDeps) return void res.status(404).json({ error: 'Not available' });
|
|
86
|
+
try {
|
|
87
|
+
const email = await callerEmail(req);
|
|
88
|
+
if (!email) return void res.status(403).json({ error: 'Forbidden' });
|
|
89
|
+
const folder = String(Array.isArray(req.params.folder) ? req.params.folder[0] : req.params.folder);
|
|
90
|
+
if (!folder || folder === '.' || folder === '..' || /[/\\]/.test(folder)) {
|
|
91
|
+
return void res.status(422).json({ error: 'Not a plugin folder name' });
|
|
92
|
+
}
|
|
93
|
+
const { workspaceService, accessControl, kbDirName } = archiveDeps;
|
|
94
|
+
const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
|
|
95
|
+
await workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
96
|
+
const wsDir = await workspaceService.getWorkspacePath(wsId);
|
|
97
|
+
const pluginDir = path.join(wsDir, kbDirName, PLUGINS_DIR, folder);
|
|
98
|
+
// SYMLINKS ARE NOT SUPPORTED IN PLUGINS, anywhere. Access control
|
|
99
|
+
// resolves rules by path, and a symlink is a second path to the same
|
|
100
|
+
// content — a standing invitation for the spelling the ACL judged and
|
|
101
|
+
// the bytes served to diverge. The platform's own write paths never
|
|
102
|
+
// create one (they arrive only via direct git pushes), so every symlink
|
|
103
|
+
// is skipped rather than resolved: no target following, no cycle
|
|
104
|
+
// guards, no read-time re-resolution — complexity that existed only to
|
|
105
|
+
// support what the platform has no use for. Starting with the plugin
|
|
106
|
+
// folder itself: a symlinked `Plugins/<folder>` is not a plugin.
|
|
107
|
+
// Only ENOENT is an absence; an EACCES/EIO answered with 404 would
|
|
108
|
+
// dress a real read problem up as a missing plugin (the same contract
|
|
109
|
+
// as the realpath/open probes below).
|
|
110
|
+
const folderStat = await fs.lstat(pluginDir).catch((err: NodeJS.ErrnoException) => {
|
|
111
|
+
if (err.code === 'ENOENT') return null;
|
|
112
|
+
throw err;
|
|
113
|
+
});
|
|
114
|
+
if (folderStat === null || !folderStat.isDirectory()) {
|
|
115
|
+
return void res.status(404).json({ error: 'Not found' });
|
|
116
|
+
}
|
|
117
|
+
const rels: string[] = [];
|
|
118
|
+
const walk = async (dir: string, rel: string): Promise<void> => {
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Only an absent directory is a non-event; anything else (EACCES,
|
|
124
|
+
// EIO) silently missing from the archive would hand the client an
|
|
125
|
+
// incomplete plugin stamped as success.
|
|
126
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
for (const e of entries) {
|
|
130
|
+
if (e.name === '.git') continue;
|
|
131
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
132
|
+
// Dirent's isDirectory/isFile are both false for a symlink, so a
|
|
133
|
+
// link is never walked and never listed — but say so, once, because
|
|
134
|
+
// an author who committed one deserves to know it went nowhere.
|
|
135
|
+
if (e.isDirectory()) await walk(path.join(dir, e.name), childRel);
|
|
136
|
+
else if (e.isFile()) rels.push(childRel);
|
|
137
|
+
else if (e.isSymbolicLink()) {
|
|
138
|
+
console.warn(`[tool-manuals] archive of "${folder}": ${childRel} is a symlink — not supported in plugins, skipped.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
await walk(pluginDir, '');
|
|
143
|
+
if (rels.length === 0) return void res.status(404).json({ error: 'Not found' });
|
|
144
|
+
const verdicts = await accessControl.canReadBatch(
|
|
145
|
+
wsId,
|
|
146
|
+
email,
|
|
147
|
+
rels.map((r) => `${PLUGINS_DIR}/${folder}/${r}`),
|
|
148
|
+
);
|
|
149
|
+
const zip = new AdmZip();
|
|
150
|
+
let included = 0;
|
|
151
|
+
let bytes = 0;
|
|
152
|
+
// The zip is built in memory; without a ceiling one plugin full of large
|
|
153
|
+
// assets is a backend OOM any key holder can trigger.
|
|
154
|
+
const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
155
|
+
// Canonical base for the read-time identity check below. The folder was
|
|
156
|
+
// lstat-verified a real directory above, so this is normalization
|
|
157
|
+
// (case, 8.3 names, drive spelling), not link resolution — but that
|
|
158
|
+
// verification is a moment old, and a plugin deleted since (a workspace
|
|
159
|
+
// reset, a merged deletion) is an ABSENCE, the same 404 it would have
|
|
160
|
+
// been a moment earlier, not an internal error.
|
|
161
|
+
const pluginRealBase = await fs.realpath(pluginDir).catch((err: NodeJS.ErrnoException) => {
|
|
162
|
+
if (err.code === 'ENOENT') return null;
|
|
163
|
+
throw err;
|
|
164
|
+
});
|
|
165
|
+
if (pluginRealBase === null) return void res.status(404).json({ error: 'Not found' });
|
|
166
|
+
for (const rel of rels) {
|
|
167
|
+
// Fail closed, per file — only an explicit `true` verdict is included.
|
|
168
|
+
if (verdicts.get(`${PLUGINS_DIR}/${folder}/${rel}`) !== true) continue;
|
|
169
|
+
const abs = path.join(pluginDir, ...rel.split('/'));
|
|
170
|
+
// The no-symlink rule, re-checked at read time over the WHOLE path.
|
|
171
|
+
// The open below guards only the final component — a PARENT directory
|
|
172
|
+
// swapped for a link between walk and read makes `abs` traverse the
|
|
173
|
+
// link with the final component still a regular file. realpath
|
|
174
|
+
// resolves every component, so demanding it equal the spelled path is
|
|
175
|
+
// exactly "no component is a link": identity, not mere containment.
|
|
176
|
+
const realNow = await fs.realpath(abs).catch((err: NodeJS.ErrnoException) => {
|
|
177
|
+
if (err.code === 'ENOENT') return null; // deleted since the walk — an absence, not a failure
|
|
178
|
+
throw err;
|
|
179
|
+
});
|
|
180
|
+
if (realNow === null || realNow !== path.join(pluginRealBase, ...rel.split('/'))) {
|
|
181
|
+
if (realNow !== null) {
|
|
182
|
+
console.warn(`[tool-manuals] archive of "${folder}": ${rel} no longer resolves to itself — skipped.`);
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Check and read through ONE file handle, so the bytes that land in
|
|
187
|
+
// the zip come from the very inode the checks passed — a path-based
|
|
188
|
+
// stat-then-readFile pair leaves a window where the path is swapped
|
|
189
|
+
// between the two and the archive ships bytes the ACL never judged.
|
|
190
|
+
// O_NOFOLLOW makes a final-component symlink fail the open itself
|
|
191
|
+
// where the platform defines it (Linux — production; Windows test
|
|
192
|
+
// runs fall back to the realpath identity check above alone). Only an
|
|
193
|
+
// ENOENT is a skip; any other failure is a real read problem that
|
|
194
|
+
// must surface as a 500, not ship as a silently partial archive.
|
|
195
|
+
const handle = await fs
|
|
196
|
+
.open(abs, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
|
|
197
|
+
.catch((err: NodeJS.ErrnoException) => {
|
|
198
|
+
if (err.code === 'ENOENT') return null; // deleted since the walk — an absence
|
|
199
|
+
if (err.code === 'ELOOP') {
|
|
200
|
+
// O_NOFOLLOW's spelling of "the final component is a symlink".
|
|
201
|
+
console.warn(`[tool-manuals] archive of "${folder}": ${rel} is a symlink — not supported in plugins, skipped.`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
});
|
|
206
|
+
if (handle === null) continue;
|
|
207
|
+
try {
|
|
208
|
+
// fstat on the handle types and sizes the OPENED file — the size
|
|
209
|
+
// still gates BEFORE content, because rejecting after the read
|
|
210
|
+
// would already have spiked memory by exactly the payload the
|
|
211
|
+
// limit exists to refuse.
|
|
212
|
+
const stat = await handle.stat();
|
|
213
|
+
if (!stat.isFile()) continue;
|
|
214
|
+
bytes += stat.size;
|
|
215
|
+
if (bytes > MAX_ARCHIVE_BYTES) {
|
|
216
|
+
return void res.status(413).json({
|
|
217
|
+
error: `Plugin "${folder}" exceeds the ${MAX_ARCHIVE_BYTES / (1024 * 1024)}MB archive limit.`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// Unix mode rides in the zip attrs so a `./`-command stdio server is
|
|
221
|
+
// still executable after materialization (0 on Windows — harmless).
|
|
222
|
+
// PLAIN mode, no shifting: adm-zip masks a numeric attr with 0xfff and
|
|
223
|
+
// positions it into the external-attribute high bits itself — a
|
|
224
|
+
// pre-shifted value is destroyed by that mask.
|
|
225
|
+
zip.addFile(rel, await handle.readFile(), '', stat.mode & 0o7777);
|
|
226
|
+
included += 1;
|
|
227
|
+
} finally {
|
|
228
|
+
await handle.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// An all-filtered plugin looks exactly like an absent one — a 404 must
|
|
232
|
+
// not confirm to a keyless caller that the folder exists.
|
|
233
|
+
if (included === 0) return void res.status(404).json({ error: 'Not found' });
|
|
234
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
235
|
+
res.send(zip.toBuffer());
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error('[tool-manuals] plugin archive failed:', err instanceof Error ? err.message : err);
|
|
238
|
+
res.status(500).json({ error: 'Failed to archive plugin' });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
66
242
|
router.get('/tools/:slug/manual', manualAuth, async (req, res) => {
|
|
67
243
|
try {
|
|
68
244
|
const email = await callerEmail(req);
|
|
@@ -89,9 +265,54 @@ export function createToolManualsAgentRoutes(
|
|
|
89
265
|
* literal sibling is shadowed by the param segment;
|
|
90
266
|
* `preview` is POST-only, so it never collides.
|
|
91
267
|
*/
|
|
92
|
-
export function createToolManualsBrowserRoutes(
|
|
268
|
+
export function createToolManualsBrowserRoutes(
|
|
269
|
+
toolManualService: IToolManualService,
|
|
270
|
+
serverEdit?: { service: McpServerEditService; getUser: (userId: string) => Promise<AuthUser | undefined> },
|
|
271
|
+
): express.Router {
|
|
93
272
|
const router = express.Router();
|
|
94
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Server-scoped read/write of one MCP server — the tool page's edit form.
|
|
276
|
+
* One server's truth spans mcp.json AND plugin.json's extensions block; a
|
|
277
|
+
* raw file editor shows a writer half of it at best, so the form talks to
|
|
278
|
+
* this pair instead. PUT commits both files together and refuses the states
|
|
279
|
+
* that would strand a half (see McpServerEditService).
|
|
280
|
+
*/
|
|
281
|
+
router.get('/tools/:slug/server', async (req, res) => {
|
|
282
|
+
if (!serverEdit) return void res.status(404).json({ error: 'Not available' });
|
|
283
|
+
const email = req.userEmail;
|
|
284
|
+
if (!email) return void res.status(401).json({ error: 'Not authenticated' });
|
|
285
|
+
try {
|
|
286
|
+
const view = await serverEdit.service.getServer(email, String(req.params.slug));
|
|
287
|
+
if (!view) return void res.status(404).json({ error: 'Not found' });
|
|
288
|
+
res.json(view);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error('[tool-manuals] server read failed:', err instanceof Error ? err.message : err);
|
|
291
|
+
res.status(500).json({ error: 'Failed to read the server' });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
router.put('/tools/:slug/server', async (req, res) => {
|
|
296
|
+
if (!serverEdit) return void res.status(404).json({ error: 'Not available' });
|
|
297
|
+
if (!req.userId) return void res.status(401).json({ error: 'Not authenticated' });
|
|
298
|
+
try {
|
|
299
|
+
const user = await serverEdit.getUser(req.userId);
|
|
300
|
+
if (!user) return void res.status(401).json({ error: 'Not authenticated' });
|
|
301
|
+
const result = await serverEdit.service.putServer(
|
|
302
|
+
user,
|
|
303
|
+
String(req.params.slug),
|
|
304
|
+
(req.body ?? {}) as McpServerWrite,
|
|
305
|
+
);
|
|
306
|
+
res.json(result);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err instanceof McpServerEditError) {
|
|
309
|
+
return void res.status(err.status).json({ error: err.message });
|
|
310
|
+
}
|
|
311
|
+
console.error('[tool-manuals] server write failed:', err instanceof Error ? err.message : err);
|
|
312
|
+
res.status(500).json({ error: 'Failed to save the server' });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
95
316
|
router.get('/tools', async (req, res) => {
|
|
96
317
|
const email = req.userEmail;
|
|
97
318
|
if (!email) return void res.status(401).json({ error: 'Not authenticated' });
|
|
@@ -9,11 +9,18 @@ import {
|
|
|
9
9
|
DefaultVariableSubstitutor,
|
|
10
10
|
type CallTemplate,
|
|
11
11
|
} from '@utcp/sdk';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_BRANCH,
|
|
14
|
+
PLUGINS_DIR,
|
|
15
|
+
PLUGIN_MANIFEST_FILE,
|
|
16
|
+
PLUGIN_MCP_FILE,
|
|
17
|
+
} from '@bevel-software/platform-shared';
|
|
18
|
+
import { descriptorsFromMcpJson } from './mcp-json-discovery.js';
|
|
13
19
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
14
20
|
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
15
21
|
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
16
22
|
import { assertSafeFetchUrl } from '../../shared/ssrf.js';
|
|
23
|
+
import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/variable-refs.js';
|
|
17
24
|
import { extractFrontmatter, resolveDeclaredId, isValidId, dedupeById } from '../../shared/frontmatter-id.js';
|
|
18
25
|
import { walkFiles } from '../../shared/fs-walk.js';
|
|
19
26
|
import { TtlCache } from '../../shared/ttl-cache.js';
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
EXTERNAL_KB_MANUAL_NAME,
|
|
28
35
|
type IToolManualService,
|
|
29
36
|
type ToolManualDescriptor,
|
|
37
|
+
type ToolManualDescriptorBase,
|
|
30
38
|
type ToolManualSummary,
|
|
31
39
|
type ToolManualDetail,
|
|
32
40
|
type ToolCapability,
|
|
@@ -60,46 +68,26 @@ const MAX_CAPABILITIES = 100;
|
|
|
60
68
|
const RESERVED_TOOL_NAMESPACES = [INTERNAL_MANUAL_NAME, EXTERNAL_KB_MANUAL_NAME].map((n) => n.toLowerCase());
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
71
|
+
* No user `.tool` may REFERENCE the platform-seeded variables (`${API_URL}` /
|
|
72
|
+
* `$API_URL`), in any `.tool` type: the only seeded user namespace is an
|
|
73
|
+
* inline `.tool`'s (its discovery template is platform-served), and a
|
|
74
|
+
* reference inside author-written content would resolve platform creds into a
|
|
75
|
+
* request the author shaped. Refusing every `.tool` at the producing boundary
|
|
76
|
+
* makes "user tools never carry platform credentials" structural rather than
|
|
77
|
+
* dependent on which namespaces happen to be seeded. The names and the
|
|
78
|
+
* reference grammar live in `shared/variable-refs.ts` — one definition for
|
|
79
|
+
* every boundary that classifies references.
|
|
72
80
|
*/
|
|
73
|
-
const RESERVED_VARIABLE_NAMES: readonly string[] = ['API_URL', 'CONNECTION_KEY'];
|
|
74
|
-
|
|
75
|
-
/** The SDK substitutor's reference grammar, exactly: `${VAR}` or `$VAR`. */
|
|
76
|
-
const VARIABLE_REFERENCE_RE = /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)/g;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* A REFERENCE is reserved by SUFFIX, not by exact name: the substitutor looks a
|
|
80
|
-
* variable up under the manual's UTCP namespace first, so `${<ns>_CONNECTION_KEY}`
|
|
81
|
-
* resolves the very same seeded value the bare `${CONNECTION_KEY}` does. Matching
|
|
82
|
-
* the bare name only would let a `.tool` reach the platform bearer just by
|
|
83
|
-
* spelling the namespace out. Suffix matching also refuses harmless-looking
|
|
84
|
-
* near-misses (`MY_API_URL`) — deliberately fail-closed: a `.tool` author who
|
|
85
|
-
* wants their own base URL has the whole namespace minus two suffixes.
|
|
86
|
-
*/
|
|
87
|
-
function isReservedVariableRef(varName: string): boolean {
|
|
88
|
-
return RESERVED_VARIABLE_NAMES.some((reserved) => varName.endsWith(reserved));
|
|
89
|
-
}
|
|
90
81
|
|
|
91
82
|
/** Throw if any string in the `.tool` document references a reserved variable. */
|
|
92
83
|
function assertNoReservedVariableRefs(doc: unknown, name: string): void {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'are seeded by the platform for its own manuals and may not appear anywhere in a `.tool`.',
|
|
101
|
-
);
|
|
102
|
-
}
|
|
84
|
+
const ref = findReservedVariableRef(doc);
|
|
85
|
+
if (ref !== null) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`\`.tool\` "${name}" references the reserved variable "${ref}" — ` +
|
|
88
|
+
'API_URL and CONNECTION_KEY (bare or namespaced, e.g. `<namespace>_CONNECTION_KEY`) ' +
|
|
89
|
+
'are seeded by the platform for its own manuals and may not appear anywhere in a `.tool`.',
|
|
90
|
+
);
|
|
103
91
|
}
|
|
104
92
|
}
|
|
105
93
|
|
|
@@ -270,7 +258,7 @@ export class ToolManualService implements IToolManualService {
|
|
|
270
258
|
async preview(content: string): Promise<ToolManualPreview> {
|
|
271
259
|
let descriptor: ToolManualDescriptor;
|
|
272
260
|
try {
|
|
273
|
-
descriptor = normalizeToolManual('draft', '
|
|
261
|
+
descriptor = normalizeToolManual('draft', 'Plugins/draft.tool', content);
|
|
274
262
|
} catch (err) {
|
|
275
263
|
return { ok: false, errors: [err instanceof Error ? err.message : String(err)] };
|
|
276
264
|
}
|
|
@@ -316,6 +304,28 @@ export class ToolManualService implements IToolManualService {
|
|
|
316
304
|
headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
|
|
317
305
|
};
|
|
318
306
|
}
|
|
307
|
+
if (m.type === 'mcp' && m.stdio) {
|
|
308
|
+
// A stdio server, for LOCAL consumers only (`remote: false` is implied
|
|
309
|
+
// at discovery). Command/args/env/cwd pass through verbatim — the Agent
|
|
310
|
+
// Plugins placeholders (`${PLUGIN_ROOT}`/`${PLUGIN_DATA}`) are expanded
|
|
311
|
+
// by the LOCAL runtime against its materialized plugin copy; this
|
|
312
|
+
// process has no such paths and must not guess them.
|
|
313
|
+
return {
|
|
314
|
+
name: m.name,
|
|
315
|
+
call_template_type: 'mcp',
|
|
316
|
+
config: {
|
|
317
|
+
mcpServers: {
|
|
318
|
+
[m.name]: {
|
|
319
|
+
transport: 'stdio',
|
|
320
|
+
command: m.stdio.command,
|
|
321
|
+
args: m.stdio.args,
|
|
322
|
+
...(m.stdio.env ? { env: m.stdio.env } : {}),
|
|
323
|
+
...(m.stdio.cwd ? { cwd: m.stdio.cwd } : {}),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
319
329
|
if (m.type === 'mcp') {
|
|
320
330
|
// Remote (HTTP/streamable) MCP server. Exact plugin field shape is
|
|
321
331
|
// finalized in Phase 4 (native `@utcp/mcp`); the proxy try/catches
|
|
@@ -487,14 +497,41 @@ export class ToolManualService implements IToolManualService {
|
|
|
487
497
|
}
|
|
488
498
|
const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
|
|
489
499
|
|
|
490
|
-
// A `.tool` sits under `
|
|
500
|
+
// A `.tool` sits under `Plugins/`, beside the skills that use it.
|
|
491
501
|
const files: { abs: string; rel: string }[] = [];
|
|
492
|
-
const root = path.join(kbRoot,
|
|
502
|
+
const root = path.join(kbRoot, PLUGINS_DIR);
|
|
493
503
|
for (const rel of await walkFiles(root, (n) => n.toLowerCase().endsWith('.tool'))) {
|
|
494
|
-
files.push({ abs: path.join(root, rel), rel: `${
|
|
504
|
+
files.push({ abs: path.join(root, rel), rel: `${PLUGINS_DIR}/${rel}` });
|
|
495
505
|
}
|
|
496
506
|
|
|
507
|
+
// MCP servers come from each plugin's mcp.json — the AUTHORITATIVE source
|
|
508
|
+
// (the Agent Plugins fixed location), synthesized into the same descriptor
|
|
509
|
+
// shape. Listed BEFORE the `.tool` files: on a name collision (a legacy
|
|
510
|
+
// mcp `.tool` the migration has not converted yet), the shared dedup keeps
|
|
511
|
+
// the first occurrence, and the authoritative source must be the one kept.
|
|
497
512
|
const parsed: ToolManualDescriptor[] = [];
|
|
513
|
+
let pluginFolders: string[] = [];
|
|
514
|
+
try {
|
|
515
|
+
pluginFolders = (await fs.readdir(root, { withFileTypes: true }))
|
|
516
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
517
|
+
.map((e) => e.name)
|
|
518
|
+
.sort();
|
|
519
|
+
} catch {
|
|
520
|
+
/* no Plugins/ root — nothing to scan */
|
|
521
|
+
}
|
|
522
|
+
for (const folder of pluginFolders) {
|
|
523
|
+
let mcpJson: string;
|
|
524
|
+
try {
|
|
525
|
+
mcpJson = await fs.readFile(path.join(root, folder, PLUGIN_MCP_FILE), 'utf-8');
|
|
526
|
+
} catch {
|
|
527
|
+
continue; // no mcp.json is the common case, not an error
|
|
528
|
+
}
|
|
529
|
+
const pluginJson = await fs
|
|
530
|
+
.readFile(path.join(root, folder, PLUGIN_MANIFEST_FILE), 'utf-8')
|
|
531
|
+
.catch(() => null);
|
|
532
|
+
parsed.push(...descriptorsFromMcpJson(folder, mcpJson, pluginJson));
|
|
533
|
+
}
|
|
534
|
+
|
|
498
535
|
for (const f of files) {
|
|
499
536
|
let content: string;
|
|
500
537
|
try {
|
|
@@ -654,7 +691,10 @@ export function normalizeToolManual(
|
|
|
654
691
|
// reference the platform-seeded variables.
|
|
655
692
|
assertNoReservedVariableRefs(obj, name);
|
|
656
693
|
|
|
657
|
-
|
|
694
|
+
// The non-stdio constituent of the union, by name: `.tool` parsing can
|
|
695
|
+
// never produce a spawn spec, and the stdio side pins `remote: false`,
|
|
696
|
+
// which this builder must stay free to set from the file's own `remote:`.
|
|
697
|
+
const descriptor: ToolManualDescriptorBase & { remote?: boolean; stdio?: undefined } = {
|
|
658
698
|
slug: provisionalSlug,
|
|
659
699
|
name,
|
|
660
700
|
path: repoPath,
|
|
@@ -179,9 +179,12 @@ async function buildListLocalToolsDef(svc: IToolManualService, userEmail?: strin
|
|
|
179
179
|
description:
|
|
180
180
|
'List tools your workspace admins configured that run ONLY in a local environment ' +
|
|
181
181
|
'(e.g. a self-hosted MCP server on localhost) and therefore cannot be called through this ' +
|
|
182
|
-
'remote endpoint.
|
|
183
|
-
'
|
|
184
|
-
'
|
|
182
|
+
'remote endpoint. To CALL them, run the workspace as a local MCP server instead of this one: ' +
|
|
183
|
+
'`npx @bevel-software/hexis-mcp --url <workspace-url> --key <connection-key>` serves every tool ' +
|
|
184
|
+
'you have here plus these, because it runs where they exist (their own credentials come from ' +
|
|
185
|
+
'that process\'s environment). Otherwise each entry gives the tool’s name and its `.tool` file ' +
|
|
186
|
+
'path in the knowledge base — read that file with `read_file` and wire the tool into your local ' +
|
|
187
|
+
'setup by hand. ' +
|
|
185
188
|
(await localToolsLine(svc, userEmail)),
|
|
186
189
|
path: '/api/agent/tools/list_local_tools',
|
|
187
190
|
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
@@ -52,6 +52,7 @@ function makeService(): PendingCommitsService {
|
|
|
52
52
|
return {
|
|
53
53
|
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
54
54
|
claimNext: vi.fn().mockResolvedValue(null),
|
|
55
|
+
hasReadyRow: vi.fn().mockResolvedValue(false),
|
|
55
56
|
markSucceeded: vi.fn().mockResolvedValue(undefined),
|
|
56
57
|
markTransientFailure: vi.fn().mockResolvedValue(undefined),
|
|
57
58
|
markRecoveryStarted: vi.fn().mockResolvedValue(undefined),
|
|
@@ -114,6 +115,8 @@ describe('PendingCommitsWorker.drainOnce', () => {
|
|
|
114
115
|
'feat/x',
|
|
115
116
|
'Foo.md',
|
|
116
117
|
expect.objectContaining<Partial<AuthUser>>({ email: 'alice@example.com', name: 'Alice' }),
|
|
118
|
+
// Last (only) row of the burst, so the advisory validator runs.
|
|
119
|
+
{ skipValidation: false },
|
|
117
120
|
);
|
|
118
121
|
expect(service.markSucceeded).toHaveBeenCalledWith('row-1');
|
|
119
122
|
expect(service.markTransientFailure).not.toHaveBeenCalled();
|
|
@@ -221,6 +224,121 @@ describe('PendingCommitsWorker.drainOnce', () => {
|
|
|
221
224
|
await expect(worker.drainOnce()).resolves.toBeUndefined();
|
|
222
225
|
expect(service.markNeedsAttention).toHaveBeenCalled();
|
|
223
226
|
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A sweep used to take exactly ONE row per workspace and then sleep
|
|
230
|
+
* `POLL_INTERVAL_MS`, capping a workspace at ~2 commits/second however fast
|
|
231
|
+
* git was. A bulk change — a migration, an agent run — paid a minute of
|
|
232
|
+
* pure polling latency, and anything waiting for the queue to settle (a
|
|
233
|
+
* change request being applied) waited with it.
|
|
234
|
+
*/
|
|
235
|
+
describe('draining a burst', () => {
|
|
236
|
+
/** Queue `count` rows, then nothing — the shape a bulk change leaves. */
|
|
237
|
+
function queue(count: number) {
|
|
238
|
+
const claim = service.claimNext as ReturnType<typeof vi.fn>;
|
|
239
|
+
const ready = service.hasReadyRow as ReturnType<typeof vi.fn>;
|
|
240
|
+
for (let i = 0; i < count; i += 1) {
|
|
241
|
+
claim.mockResolvedValueOnce(makeRow({ id: `row-${i}`, path: `File${i}.md` }));
|
|
242
|
+
// True until the last row is the one in hand.
|
|
243
|
+
ready.mockResolvedValueOnce(i < count - 1);
|
|
244
|
+
}
|
|
245
|
+
claim.mockResolvedValue(null);
|
|
246
|
+
ready.mockResolvedValue(false);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
it('commits every queued row in ONE pass', async () => {
|
|
250
|
+
queue(12);
|
|
251
|
+
await worker.drainOnce();
|
|
252
|
+
expect(workflow.runPendingCommit).toHaveBeenCalledTimes(12);
|
|
253
|
+
expect(service.markSucceeded).toHaveBeenCalledTimes(12);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('validates once — on the last commit, whose tree is the end state', async () => {
|
|
257
|
+
// The validator parses the whole KB for a report that is only logged,
|
|
258
|
+
// so per-file runs cost a full parse each and all say the same thing.
|
|
259
|
+
queue(5);
|
|
260
|
+
await worker.drainOnce();
|
|
261
|
+
const skipped = workflow.runPendingCommit.mock.calls.map((c) => c[4]?.skipValidation);
|
|
262
|
+
expect(skipped).toEqual([true, true, true, true, false]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('stops at the burst ceiling so one workspace cannot starve the rest', async () => {
|
|
266
|
+
// The loop holds the single in-flight commit slot; an unbounded drain
|
|
267
|
+
// would park every other user's save behind a huge migration.
|
|
268
|
+
queue(200);
|
|
269
|
+
await worker.drainOnce();
|
|
270
|
+
expect(workflow.runPendingCommit).toHaveBeenCalledTimes(50);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('gives up the pass the moment stop() lands mid-burst', async () => {
|
|
274
|
+
queue(12);
|
|
275
|
+
workflow.runPendingCommit.mockImplementation(async () => {
|
|
276
|
+
(worker as unknown as { running: boolean }).running = false;
|
|
277
|
+
});
|
|
278
|
+
await worker.drainOnce();
|
|
279
|
+
expect(workflow.runPendingCommit).toHaveBeenCalledTimes(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('carries on after a transient failure and ends the pass when nothing is ready', async () => {
|
|
283
|
+
// What keeps the drain loop from SPINNING on a failing row is the SQL
|
|
284
|
+
// backoff gate: `markTransientFailure` leaves `lastAttemptedAt` set, so
|
|
285
|
+
// `claimNext` refuses that row until its backoff elapses. That gate is
|
|
286
|
+
// not exercised here — `claimNext` is a stub, so this would pass with
|
|
287
|
+
// the gate removed. It lives in `readyPredicate`, shared by `claimNext`
|
|
288
|
+
// and `hasReadyRow` precisely so the two cannot drift; there is no
|
|
289
|
+
// database-backed test for it in this package.
|
|
290
|
+
//
|
|
291
|
+
// What this DOES pin is the loop's own half of the contract: a failure
|
|
292
|
+
// is recorded and the pass keeps going rather than aborting the sweep,
|
|
293
|
+
// and the row is attempted once.
|
|
294
|
+
(service.claimNext as ReturnType<typeof vi.fn>)
|
|
295
|
+
.mockResolvedValueOnce(makeRow({ id: 'bad' }))
|
|
296
|
+
.mockResolvedValueOnce(makeRow({ id: 'good' }))
|
|
297
|
+
.mockResolvedValue(null);
|
|
298
|
+
workflow.runPendingCommit
|
|
299
|
+
.mockRejectedValueOnce(new Error('push rejected'))
|
|
300
|
+
.mockResolvedValueOnce(undefined);
|
|
301
|
+
|
|
302
|
+
await worker.drainOnce();
|
|
303
|
+
|
|
304
|
+
expect(service.markTransientFailure).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(service.markSucceeded).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(workflow.runPendingCommit).toHaveBeenCalledTimes(2);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('validates the final slot when the burst hits its ceiling', async () => {
|
|
310
|
+
// Rows remain queued, so the peek says "more" — but this is the last
|
|
311
|
+
// commit of the PASS, and skipping it would end every sweep of a big
|
|
312
|
+
// backlog on an unvalidated commit.
|
|
313
|
+
queue(200);
|
|
314
|
+
await worker.drainOnce();
|
|
315
|
+
const calls = workflow.runPendingCommit.mock.calls;
|
|
316
|
+
expect(calls).toHaveLength(50);
|
|
317
|
+
expect(calls[49]?.[4]?.skipValidation).toBe(false);
|
|
318
|
+
expect(calls[48]?.[4]?.skipValidation).toBe(true);
|
|
319
|
+
// …and does not ASK on that slot: the ceiling already answers it, so
|
|
320
|
+
// peeking would be a wasted round-trip on every sweep of a backlog.
|
|
321
|
+
expect(service.hasReadyRow).toHaveBeenCalledTimes(49);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('commits the claimed row even if the peek throws', async () => {
|
|
325
|
+
// The row is already claimed and `running`; nothing resets that, so
|
|
326
|
+
// throwing out of the peek would strand it and the workspace would stop
|
|
327
|
+
// draining until the process restarted. A failed peek assumes "last",
|
|
328
|
+
// which costs one extra validation and nothing else.
|
|
329
|
+
(service.claimNext as ReturnType<typeof vi.fn>)
|
|
330
|
+
.mockResolvedValueOnce(makeRow({ id: 'claimed' }))
|
|
331
|
+
.mockResolvedValue(null);
|
|
332
|
+
(service.hasReadyRow as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
333
|
+
new Error('connection reset'),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await expect(worker.drainOnce()).resolves.toBeUndefined();
|
|
337
|
+
|
|
338
|
+
expect(service.markSucceeded).toHaveBeenCalledTimes(1);
|
|
339
|
+
expect(workflow.runPendingCommit.mock.calls[0]?.[4]?.skipValidation).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
224
342
|
});
|
|
225
343
|
|
|
226
344
|
describe('PendingCommitsWorker lifecycle', () => {
|
|
@@ -93,7 +93,7 @@ function makeAccessControl(): IAccessControl {
|
|
|
93
93
|
grantSources: vi.fn().mockResolvedValue({}),
|
|
94
94
|
invalidate: vi.fn(),
|
|
95
95
|
findEmailByHash: vi.fn().mockResolvedValue(null),
|
|
96
|
-
kbPrincipals: vi.fn().mockResolvedValue({
|
|
96
|
+
kbPrincipals: vi.fn().mockResolvedValue({ plugins: [], people: [] }),
|
|
97
97
|
validateRolesYaml: vi.fn().mockReturnValue({ ok: true }),
|
|
98
98
|
referencesToRole: vi.fn().mockResolvedValue([]),
|
|
99
99
|
canWriteAtRef: vi.fn().mockResolvedValue(null),
|
|
@@ -49,9 +49,9 @@ describe('assertValidRelativePath', () => {
|
|
|
49
49
|
// now sets `GIT_LITERAL_PATHSPECS=1` on every git subprocess, so KB files
|
|
50
50
|
// arriving with bracketed prefixes (`[Approved]`, `[New]`, `[Updated …]`)
|
|
51
51
|
// or other glob metacharacters round-trip through commit cleanly.
|
|
52
|
-
'[Approved]
|
|
53
|
-
'
|
|
54
|
-
'
|
|
52
|
+
'[Approved] Handbook_Order archive.docx',
|
|
53
|
+
'Handbook/[New] Handbook_E-Invoicing DE.docx',
|
|
54
|
+
'Handbook/[Updated 03.09.2025] Handbook_How to search_Detail page.docx',
|
|
55
55
|
'docs/wildcard*name.md',
|
|
56
56
|
'docs/question?name.md',
|
|
57
57
|
'docs/bang!name.md',
|
|
@@ -124,7 +124,7 @@ function recordingAccessControl(opts: {
|
|
|
124
124
|
eligibleWritersForPathsAtRef: async (_w, _ref, paths) =>
|
|
125
125
|
new Map(paths.map((p) => [p, { roles: [], users: [], emails: new Set<string>() }])),
|
|
126
126
|
findEmailByHash: async () => null,
|
|
127
|
-
kbPrincipals: async () => ({
|
|
127
|
+
kbPrincipals: async () => ({ plugins: [], people: [] }),
|
|
128
128
|
validateRolesYaml: () => ({ ok: true }),
|
|
129
129
|
referencesToRole: async () => [],
|
|
130
130
|
};
|
|
@@ -279,8 +279,8 @@ describe('GitService — push gate uses origin/<branch> (not HEAD or working tre
|
|
|
279
279
|
});
|
|
280
280
|
|
|
281
281
|
it('systemAuthorized skips the gate entirely, and the push still lands', async () => {
|
|
282
|
-
// The
|
|
283
|
-
// signed-in user may claim an unused name under
|
|
282
|
+
// The plugin-provisioning path: its endpoint IS the authorization (any
|
|
283
|
+
// signed-in user may claim an unused name under Plugins/), and the gate
|
|
284
284
|
// could only read the new folder's chain at origin as `write: Admin`.
|
|
285
285
|
const { svc, calls } = await setupWithOrigin();
|
|
286
286
|
await svc.push(workspaceId, USER, { systemAuthorized: true });
|