@bevel-software/platform-core-backend 0.25.2 → 0.26.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/agent-guide/access-control.md +234 -0
- package/agent-guide/conventions.md +27 -0
- package/agent-guide/directory-structure.md +145 -0
- package/agent-guide/finding-things.md +7 -0
- package/agent-guide/introduction.md +27 -0
- package/agent-guide/skills.md +47 -0
- package/agent-guide/tool-manuals.md +217 -0
- package/agent-guide/where-a-new-file-goes.md +36 -0
- package/dist/assets.d.ts +7 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +9 -0
- package/dist/assets.js.map +1 -1
- package/dist/core/core-ports.d.ts +11 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +13 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +14 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +9 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +16 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
- package/dist/modules/agent-guide/agent-guide.js +191 -0
- package/dist/modules/agent-guide/agent-guide.js.map +1 -0
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
- package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +4 -0
- package/dist/modules/agent-guide/index.d.ts.map +1 -0
- package/dist/modules/agent-guide/index.js +4 -0
- package/dist/modules/agent-guide/index.js.map +1 -0
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +9 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +9 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +1 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +1 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +29 -2
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +113 -16
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
- package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
- package/dist/modules/mcp/tool-schema-guard.js +171 -0
- package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
- package/dist/modules/plugins/plugins.tools.d.ts +36 -2
- package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.tools.js +71 -14
- package/dist/modules/plugins/plugins.tools.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +14 -53
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +3 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +58 -16
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +14 -14
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
- package/dist/modules/tool-registry/description-length.js +24 -26
- package/dist/modules/tool-registry/description-length.js.map +1 -1
- package/dist/modules/tool-registry/guide-first.d.ts +23 -0
- package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
- package/dist/modules/tool-registry/guide-first.js +32 -0
- package/dist/modules/tool-registry/guide-first.js.map +1 -0
- package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
- package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
- package/dist/modules/tool-registry/tool-registry.js +9 -2
- package/dist/modules/tool-registry/tool-registry.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +210 -13
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +456 -91
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
- package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
- package/dist/modules/workflow/git/merge-commit.js +89 -0
- package/dist/modules/workflow/git/merge-commit.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +332 -37
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +6 -2
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +7 -2
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +4 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +3 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +5 -3
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +10 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +211 -18
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +11 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +14 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/hidden-tools.d.ts +44 -0
- package/dist/shared/hidden-tools.d.ts.map +1 -0
- package/dist/shared/hidden-tools.js +13 -0
- package/dist/shared/hidden-tools.js.map +1 -0
- package/kb-template/.bevelignore +0 -5
- package/package.json +4 -3
- package/src/__tests__/kb-layout-config.test.ts +10 -100
- package/src/__tests__/packaged-assets-ship.test.ts +54 -0
- package/src/assets.ts +10 -0
- package/src/core/core-ports.ts +11 -0
- package/src/core/create-core-server.ts +13 -2
- package/src/core/create-core-services.ts +28 -4
- package/src/index.ts +2 -2
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
- package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
- package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
- package/src/modules/access/access-control.interface.ts +15 -0
- package/src/modules/access/access-control.service.ts +21 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
- package/src/modules/agent-guide/agent-guide.ts +291 -0
- package/src/modules/agent-guide/index.ts +21 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
- package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
- package/src/modules/agent-instructions/compose.ts +9 -6
- package/src/modules/agent-instructions/index.ts +0 -3
- package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
- package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
- package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
- package/src/modules/mcp/mcp.service.ts +137 -19
- package/src/modules/mcp/tool-schema-guard.ts +196 -0
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
- package/src/modules/plugins/plugins.tools.ts +75 -15
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
- package/src/modules/settings/deployment-settings.service.ts +13 -54
- package/src/modules/settings/setup.routes.ts +3 -6
- package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
- package/src/modules/skills/skills.tools.ts +62 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
- package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
- package/src/modules/tool-registry/description-length.ts +24 -26
- package/src/modules/tool-registry/guide-first.ts +34 -0
- package/src/modules/tool-registry/tool-registry.ts +9 -2
- package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
- package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
- package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
- package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
- package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
- package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
- package/src/modules/workflow/git/git.service.ts +537 -94
- package/src/modules/workflow/git/merge-commit.ts +88 -0
- package/src/modules/workflow/git/pull-request.service.ts +380 -54
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
- package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
- package/src/modules/workflow/workflow.routes.ts +7 -2
- package/src/modules/workflow/workflow.service.ts +7 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
- package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
- package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
- package/src/modules/workspace/startup/steps/template-source.ts +5 -3
- package/src/modules/workspace/workspace.tools.ts +226 -16
- package/src/shared/domain-errors.ts +15 -0
- package/src/shared/hidden-tools.ts +45 -0
- package/kb-template/AGENTS.md +0 -730
|
@@ -7,7 +7,6 @@ import { deploymentSettings } from '../database/core-schema.js';
|
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_KB_LAYOUT,
|
|
9
9
|
type KbLayout,
|
|
10
|
-
validateAgentsFileName,
|
|
11
10
|
validateBranchModel,
|
|
12
11
|
validateKbLayout,
|
|
13
12
|
validateKbRootName,
|
|
@@ -228,14 +227,16 @@ export const CORE_SETTINGS: SettingDef[] = [
|
|
|
228
227
|
/**
|
|
229
228
|
* The KB layout: the three root folders a deployment may rename so hexis can
|
|
230
229
|
* read a repository laid out by someone else (`skills/` and `plugins/` in
|
|
231
|
-
* lowercase, say)
|
|
230
|
+
* lowercase, say). The agent guide's name is not among them any more: the
|
|
231
|
+
* guide is served by the platform as `AGENTS.md` everywhere, and a name a
|
|
232
|
+
* deployment saved for the written guide is ignored.
|
|
232
233
|
* Restart-to-apply like the branch model — the names are applied once at boot
|
|
233
234
|
* through `configureKbLayout` and served to the browser once by
|
|
234
235
|
* `/api/config`. Each has a default, so an unset field means the default, not
|
|
235
|
-
* an unconfigured deployment. Checked as a
|
|
236
|
+
* an unconfigured deployment. Checked as a TRIO in `save`: the three must
|
|
236
237
|
* differ, and one field alone cannot see the other three.
|
|
237
238
|
*
|
|
238
|
-
* NO `envVar`, on any of the
|
|
239
|
+
* NO `envVar`, on any of the three. Layout is deployment configuration that is
|
|
239
240
|
* entered once in the app; the three that used to be environment-driven are
|
|
240
241
|
* imported into their saved setting on the first boot after the upgrade
|
|
241
242
|
* ({@link DeploymentSettingsService.importLegacyLayoutEnv}) so nothing
|
|
@@ -262,37 +263,6 @@ export const CORE_SETTINGS: SettingDef[] = [
|
|
|
262
263
|
restartToApply: true,
|
|
263
264
|
unsetMeans: DEFAULT_KB_LAYOUT.pluginsDir,
|
|
264
265
|
},
|
|
265
|
-
{
|
|
266
|
-
/**
|
|
267
|
-
* The managed agent guide's file name. Its own validator says what a guide
|
|
268
|
-
* may be called; the quartet check in `plan` is what keeps it clear of the
|
|
269
|
-
* three folder names it is saved beside.
|
|
270
|
-
*/
|
|
271
|
-
key: 'agentsFile',
|
|
272
|
-
section: 'knowledge-base',
|
|
273
|
-
// Judged against the default roots here; the quartet check in `plan`
|
|
274
|
-
// judges it against the roots the same save puts in effect.
|
|
275
|
-
validate: (v) => validateAgentsFileName(v, DEFAULT_KB_LAYOUT),
|
|
276
|
-
restartToApply: true,
|
|
277
|
-
unsetMeans: DEFAULT_KB_LAYOUT.agentsFile,
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
/**
|
|
281
|
-
* Whether to keep the platform's one-sentence pointer in a customer's own
|
|
282
|
-
* `AGENTS.md` — the admin's consent to the only text the platform ever adds
|
|
283
|
-
* to a file it does not own. On unless it is explicitly turned off, because
|
|
284
|
-
* a renamed guide nothing points at is a guide no coding agent will find.
|
|
285
|
-
*
|
|
286
|
-
* Restart-to-apply like the name it belongs to: the check runs once per
|
|
287
|
-
* start, in the KB startup phase.
|
|
288
|
-
*/
|
|
289
|
-
key: 'agentsFileLink',
|
|
290
|
-
section: 'knowledge-base',
|
|
291
|
-
validate: (v) => (v === 'true' || v === 'false' ? null : 'Use "true" or "false".'),
|
|
292
|
-
restartToApply: true,
|
|
293
|
-
// On unless explicitly turned off — the reading `resolveAgentsFileLink` applies.
|
|
294
|
-
unsetMeans: 'true',
|
|
295
|
-
},
|
|
296
266
|
|
|
297
267
|
|
|
298
268
|
/**
|
|
@@ -587,20 +557,10 @@ export class DeploymentSettingsService {
|
|
|
587
557
|
knowledgeBaseDir: this.resolve('knowledgeBaseDir') || DEFAULT_KB_LAYOUT.knowledgeBaseDir,
|
|
588
558
|
skillsDir: this.resolve('skillsDir') || DEFAULT_KB_LAYOUT.skillsDir,
|
|
589
559
|
pluginsDir: this.resolve('pluginsDir') || DEFAULT_KB_LAYOUT.pluginsDir,
|
|
590
|
-
agentsFile:
|
|
560
|
+
agentsFile: DEFAULT_KB_LAYOUT.agentsFile,
|
|
591
561
|
};
|
|
592
562
|
}
|
|
593
563
|
|
|
594
|
-
/**
|
|
595
|
-
* Whether the platform should keep its pointer sentence in a customer-owned
|
|
596
|
-
* `AGENTS.md`. On unless the admin turned it off — an unset setting is a
|
|
597
|
-
* deployment that never saw the checkbox, and the sentence is what makes a
|
|
598
|
-
* renamed guide findable at all.
|
|
599
|
-
*/
|
|
600
|
-
resolveAgentsFileLink(): boolean {
|
|
601
|
-
return this.resolve('agentsFileLink') !== 'false';
|
|
602
|
-
}
|
|
603
|
-
|
|
604
564
|
/**
|
|
605
565
|
* Import the retired layout environment variables into their saved settings,
|
|
606
566
|
* ONCE, on a deployment that still sets them.
|
|
@@ -832,12 +792,12 @@ export class DeploymentSettingsService {
|
|
|
832
792
|
if (problem) problems.protectedBranches = problem;
|
|
833
793
|
}
|
|
834
794
|
|
|
835
|
-
// The layout
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
const layoutKeys = ['knowledgeBaseDir', 'skillsDir', 'pluginsDir'
|
|
795
|
+
// The layout trio is the other cross-field rule: three folder names that
|
|
796
|
+
// must all differ. Judged on the layout this save WOULD produce, with the
|
|
797
|
+
// default standing in for anything neither written nor stored — so
|
|
798
|
+
// renaming one folder to what another is already called is refused
|
|
799
|
+
// whichever of the two the save names.
|
|
800
|
+
const layoutKeys = ['knowledgeBaseDir', 'skillsDir', 'pluginsDir'] as const;
|
|
841
801
|
if (toWrite.some((w) => (layoutKeys as readonly string[]).includes(w.key))) {
|
|
842
802
|
const effective = (key: (typeof layoutKeys)[number]) =>
|
|
843
803
|
toWrite.find((w) => w.key === key)?.value || this.resolve(key) || DEFAULT_KB_LAYOUT[key];
|
|
@@ -845,7 +805,6 @@ export class DeploymentSettingsService {
|
|
|
845
805
|
knowledgeBaseDir: effective('knowledgeBaseDir'),
|
|
846
806
|
skillsDir: effective('skillsDir'),
|
|
847
807
|
pluginsDir: effective('pluginsDir'),
|
|
848
|
-
agentsFile: effective('agentsFile'),
|
|
849
808
|
});
|
|
850
809
|
// Against the field being written — the first one in the batch — since
|
|
851
810
|
// any of the three could be the one that collides.
|
|
@@ -873,7 +832,7 @@ export class DeploymentSettingsService {
|
|
|
873
832
|
knowledgeBaseDir: effective('knowledgeBaseDir'),
|
|
874
833
|
skillsDir: effective('skillsDir'),
|
|
875
834
|
pluginsDir: effective('pluginsDir'),
|
|
876
|
-
agentsFile:
|
|
835
|
+
agentsFile: DEFAULT_KB_LAYOUT.agentsFile,
|
|
877
836
|
});
|
|
878
837
|
} catch (err) {
|
|
879
838
|
// Against the field this save is writing — the checkout name when that
|
|
@@ -64,17 +64,14 @@ const SECRET_FOR_THAT_PROVIDER =
|
|
|
64
64
|
'Enter the application secret for that provider — the saved one is only sent to the provider it was saved for.';
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* The knowledge-base layout as setting keys: the three renameable roots
|
|
68
|
-
* guide's file name
|
|
69
|
-
*
|
|
70
|
-
* with the names.
|
|
67
|
+
* The knowledge-base layout as setting keys: the three renameable roots. The
|
|
68
|
+
* guide's file name is not one of them — the settings layer sets none, so the
|
|
69
|
+
* layout it produces carries the default, `AGENTS.md`.
|
|
71
70
|
*/
|
|
72
71
|
const LAYOUT_KEYS: readonly string[] = [
|
|
73
72
|
'knowledgeBaseDir',
|
|
74
73
|
'skillsDir',
|
|
75
74
|
'pluginsDir',
|
|
76
|
-
'agentsFile',
|
|
77
|
-
'agentsFileLink',
|
|
78
75
|
];
|
|
79
76
|
/** The branch model, as setting keys. */
|
|
80
77
|
const BRANCH_KEYS: readonly string[] = ['defaultBranch', 'protectedBranches'];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
4
|
+
import { TOOL_DESCRIPTION_CAP, clientVisibleLength } from '../../tool-registry/description-length.js';
|
|
5
|
+
import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
|
|
6
|
+
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
7
|
+
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
|
+
import { registerSkillsTools } from '../skills.tools.js';
|
|
9
|
+
import type { ISkillService, Skill } from '../skills.contract.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The two skill tools name the skills the caller may read in their
|
|
13
|
+
* descriptions. The catalog is the organisation's to grow, and a client cuts
|
|
14
|
+
* a long description from the END — so the names are the part that gives way,
|
|
15
|
+
* never the tool's own text, and never an id cut in half.
|
|
16
|
+
*/
|
|
17
|
+
describe('the skill tools name the available skills within the description cap', () => {
|
|
18
|
+
const unused = () => new Proxy({}, { get: () => undefined }) as never;
|
|
19
|
+
|
|
20
|
+
async function served(skills: readonly Skill[]) {
|
|
21
|
+
const registry = new ToolRegistry();
|
|
22
|
+
const skillService = { listSkills: async () => skills } as unknown as ISkillService;
|
|
23
|
+
registerSkillsTools(registry, express.Router(), ((_req, _res, next) => next()) as unknown as ToolAuth, createToolHandlerFactory(unused()), skillService);
|
|
24
|
+
const tools = await registry.listExternal();
|
|
25
|
+
return {
|
|
26
|
+
list: tools.find((t) => t.name === 'list_skills')!,
|
|
27
|
+
get: tools.find((t) => t.name === 'get_skill')!,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const skill = (name: string): Skill => ({ name, description: '', path: `Skills/${name}` }) as unknown as Skill;
|
|
32
|
+
|
|
33
|
+
it('names every skill when they fit, and says so plainly when there are none', async () => {
|
|
34
|
+
const few = await served(['rfi', 'deck-review'].map(skill));
|
|
35
|
+
for (const tool of [few.list, few.get]) {
|
|
36
|
+
expect(tool.description).toContain('Currently available skills: `rfi`, `deck-review`.');
|
|
37
|
+
expect(tool.description).not.toContain(' more');
|
|
38
|
+
}
|
|
39
|
+
const none = await served([]);
|
|
40
|
+
expect(none.list.description).toContain('No skills are currently available.');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('lists every skill when the complete line fits, even where a shorter list plus its count would not', async () => {
|
|
44
|
+
// The budget is what the tool's fixed text leaves under the cap once the
|
|
45
|
+
// guide-first sentence is counted — read off a served description rather
|
|
46
|
+
// than guessed, so the names below are sized to fill it EXACTLY.
|
|
47
|
+
const probe = await served([skill('x')]);
|
|
48
|
+
const fixed = probe.list.description!.slice(GUIDE_FIRST_SENTENCE.length + 1, probe.list.description!.indexOf('Currently available skills: '));
|
|
49
|
+
const budget = TOOL_DESCRIPTION_CAP - GUIDE_FIRST_SENTENCE.length - 1 - fixed.length;
|
|
50
|
+
const head = 'Currently available skills: '.length;
|
|
51
|
+
// Twenty-one names, the LAST one short — shorter than the ", and 1 more…"
|
|
52
|
+
// tail a cut before it would carry — and the first one sized so the
|
|
53
|
+
// complete line is the budget to the character. A cut-then-count loop
|
|
54
|
+
// stops short of the complete list here (a prefix plus its count tail is
|
|
55
|
+
// over budget well before the last name) though the complete line fits.
|
|
56
|
+
const count = 21;
|
|
57
|
+
const base = Math.floor((budget - head - 1 - (count - 1) * 2 - count * 2) / count); // chars inside the backticks
|
|
58
|
+
const names = Array.from({ length: count }, (_, i) => `n${i}`.padEnd(base, 'x'));
|
|
59
|
+
names[count - 1] = 'z';
|
|
60
|
+
const slack = budget - (head + names.reduce((n, name) => n + name.length + 2, 0) + (count - 1) * 2 + 1);
|
|
61
|
+
names[0] = names[0] + 'y'.repeat(slack);
|
|
62
|
+
const { list } = await served(names.map(skill));
|
|
63
|
+
const line = list.description!.slice(list.description!.indexOf('Currently available skills: '));
|
|
64
|
+
expect(line.length).toBe(budget);
|
|
65
|
+
expect(line.endsWith('.')).toBe(true);
|
|
66
|
+
expect(line).not.toContain(' more');
|
|
67
|
+
for (const name of names) expect(line).toContain(`\`${name}\``);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('cuts the list to what fits under the cap with the guide-first sentence counted, and counts the rest', async () => {
|
|
71
|
+
const many = Array.from({ length: 300 }, (_, i) => skill(`a-skill-with-a-long-name-number-${i}`));
|
|
72
|
+
const { list, get } = await served(many);
|
|
73
|
+
for (const tool of [list, get]) {
|
|
74
|
+
// Measured as the registry serves it: the opener in front.
|
|
75
|
+
expect(clientVisibleLength(tool), tool.name).toBeLessThanOrEqual(TOOL_DESCRIPTION_CAP);
|
|
76
|
+
expect(tool.description!.startsWith(GUIDE_FIRST_SENTENCE), tool.name).toBe(true);
|
|
77
|
+
// The tool's own text is whole; the names are what gave way.
|
|
78
|
+
expect(tool.description, tool.name).toContain('Currently available skills: `a-skill-with-a-long-name-number-0`');
|
|
79
|
+
const counted = /, and (\d+) more that list_skills names\.$/.exec(tool.description!);
|
|
80
|
+
expect(counted, tool.name).not.toBeNull();
|
|
81
|
+
const shown = (tool.description!.match(/`a-skill-with-a-long-name-number-\d+`/g) ?? []).length;
|
|
82
|
+
expect(shown + Number(counted![1]), tool.name).toBe(300);
|
|
83
|
+
// No name is cut in half: every one listed is a real one, closing
|
|
84
|
+
// backtick included — a dangling name (no closing backtick) is caught
|
|
85
|
+
// because the scan does not require one.
|
|
86
|
+
for (const name of tool.description!.match(/`a-skill-with-a-long-name-number-[^`,.]*`?/g) ?? []) {
|
|
87
|
+
expect(name, tool.name).toMatch(/^`a-skill-with-a-long-name-number-\d+`$/);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Router, RequestHandler } from 'express';
|
|
2
2
|
import type { IToolRegistry, UtcpTool } from '../tool-registry/tool.contract.js';
|
|
3
|
+
import { TOOL_DESCRIPTION_CAP } from '../tool-registry/description-length.js';
|
|
4
|
+
import { GUIDE_FIRST_SENTENCE } from '../tool-registry/guide-first.js';
|
|
3
5
|
import { ToolError, type ToolContext } from '../tool-helpers/tool.contract.js';
|
|
4
6
|
import { toolDef } from '../tool-helpers/tool-def.js';
|
|
5
7
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
@@ -97,25 +99,66 @@ function branchArg(args: Record<string, unknown>): string | undefined {
|
|
|
97
99
|
return typeof branch === 'string' && branch.trim().length > 0 ? branch : undefined;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
+
/**
|
|
103
|
+
* "Currently available skills: `a`, `b`." (or a no-skills note), filtered to
|
|
104
|
+
* what the caller may read, and cut to `budget` characters: as many names as
|
|
105
|
+
* fit, then a count of the rest. The names are a convenience — `list_skills`
|
|
106
|
+
* is the complete answer — and a description a client cuts from the end
|
|
107
|
+
* would lose the tool's own tail to a catalog that grew; the budget is what
|
|
108
|
+
* the tool's fixed text leaves under {@link TOOL_DESCRIPTION_CAP} once the
|
|
109
|
+
* guide-first sentence the registry puts in front is counted.
|
|
110
|
+
*/
|
|
111
|
+
async function availableSkillsLine(skillService: ISkillService, userEmail: string | undefined, budget: number): Promise<string> {
|
|
102
112
|
const skills = await skillService.listSkills(userEmail);
|
|
103
113
|
if (skills.length === 0) return 'No skills are currently available.';
|
|
104
|
-
|
|
114
|
+
const names = skills.map((s) => `\`${s.name}\``);
|
|
115
|
+
const rest = (shown: number): string =>
|
|
116
|
+
shown < names.length ? `, and ${names.length - shown} more that list_skills names.` : '.';
|
|
117
|
+
const head = 'Currently available skills: ';
|
|
118
|
+
// The complete line first: it ends in a full stop, not in a count, so it
|
|
119
|
+
// can fit where a shorter list plus its "and N more" tail would not.
|
|
120
|
+
const complete = `${head}${names.join(', ')}.`;
|
|
121
|
+
if (complete.length <= budget) return complete;
|
|
122
|
+
// Otherwise one pass, accumulating: the cut is where the next name — with
|
|
123
|
+
// the separator before it and the tail that would follow it — no longer
|
|
124
|
+
// fits. (Rebuilding the joined prefix per candidate made this quadratic in
|
|
125
|
+
// the catalog's size, on every catalog listing.)
|
|
126
|
+
let shown = 0;
|
|
127
|
+
let length = head.length;
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const added = (shown > 0 ? 2 : 0) + name.length;
|
|
130
|
+
if (length + added + rest(shown + 1).length > budget) break;
|
|
131
|
+
length += added;
|
|
132
|
+
shown += 1;
|
|
133
|
+
}
|
|
134
|
+
if (shown === 0) {
|
|
135
|
+
return names.length === 1
|
|
136
|
+
? '1 skill is currently available; list_skills names it.'
|
|
137
|
+
: `${names.length} skills are currently available; list_skills names them.`;
|
|
138
|
+
}
|
|
139
|
+
return `${head}${names.slice(0, shown).join(', ')}${rest(shown)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the fixed part of a description leaves the skills line, with the guide-first sentence counted. */
|
|
143
|
+
function skillsLineBudget(fixed: string): number {
|
|
144
|
+
return TOOL_DESCRIPTION_CAP - GUIDE_FIRST_SENTENCE.length - 1 - fixed.length;
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
const LIST_SKILLS_DESCRIPTION =
|
|
148
|
+
'List the available skills (reusable specialist instructions) with their names, descriptions and, ' +
|
|
149
|
+
'for a skill that declares one, its current `version` (its SKILL.md `metadata.version`, else a ' +
|
|
150
|
+
'top-level `version`, else `lifecycle.version`). ' +
|
|
151
|
+
'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
|
|
152
|
+
'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
|
|
153
|
+
'what you need to try a skill you just wrote there; each skill that differs from the released ' +
|
|
154
|
+
'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ';
|
|
155
|
+
|
|
107
156
|
async function buildListSkillsDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
|
|
108
157
|
return toolDef({
|
|
109
158
|
name: 'list_skills',
|
|
110
159
|
description:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'top-level `version`, else `lifecycle.version`). ' +
|
|
114
|
-
'Discover what skills exist before specialist work, then `get_skill` to load one. ' +
|
|
115
|
-
'Pass `branch` to list the skills as they are on a draft branch instead of the released set — ' +
|
|
116
|
-
'what you need to try a skill you just wrote there; each skill that differs from the released ' +
|
|
117
|
-
'one comes back with `unmerged: true` and that branch, meaning nobody has approved it. ' +
|
|
118
|
-
(await availableSkillsLine(skillService, userEmail)),
|
|
160
|
+
LIST_SKILLS_DESCRIPTION +
|
|
161
|
+
(await availableSkillsLine(skillService, userEmail, skillsLineBudget(LIST_SKILLS_DESCRIPTION))),
|
|
119
162
|
path: '/api/agent/tools/list_skills',
|
|
120
163
|
inputs: {
|
|
121
164
|
type: 'object',
|
|
@@ -156,15 +199,18 @@ async function buildListSkillsDef(skillService: ISkillService, userEmail?: strin
|
|
|
156
199
|
});
|
|
157
200
|
}
|
|
158
201
|
|
|
202
|
+
const GET_SKILL_DESCRIPTION =
|
|
203
|
+
'Load a skill by name: returns its full instructions (SKILL.md body) to follow, plus the skill ' +
|
|
204
|
+
'folder path and the list of bundled files. Pass `file` to fetch a bundled file’s content ' +
|
|
205
|
+
'(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
|
|
206
|
+
'one the skill declared, or `branch` names a draft to load it from. ';
|
|
207
|
+
|
|
159
208
|
async function buildGetSkillDef(skillService: ISkillService, userEmail?: string): Promise<UtcpTool> {
|
|
160
209
|
return toolDef({
|
|
161
210
|
name: 'get_skill',
|
|
162
211
|
description:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
'(e.g. a script) instead of the body. Loads the latest copy unless `version` names an earlier ' +
|
|
166
|
-
'one the skill declared, or `branch` names a draft to load it from. ' +
|
|
167
|
-
(await availableSkillsLine(skillService, userEmail)),
|
|
212
|
+
GET_SKILL_DESCRIPTION +
|
|
213
|
+
(await availableSkillsLine(skillService, userEmail, skillsLineBudget(GET_SKILL_DESCRIPTION))),
|
|
168
214
|
path: '/api/agent/tools/get_skill',
|
|
169
215
|
inputs: {
|
|
170
216
|
type: 'object',
|
|
@@ -34,6 +34,7 @@ const DETAIL: ToolManualDetail = {
|
|
|
34
34
|
type: 'inline',
|
|
35
35
|
description: 'Read and write GitHub issues and PRs.',
|
|
36
36
|
capabilities: [{ name: 'create_issue', description: 'Open an issue in a repo.' }],
|
|
37
|
+
hiddenTools: [],
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
let httpServer: HttpServer | undefined;
|
|
@@ -205,6 +206,62 @@ describe('ToolManualService.getDetail — capabilities + access', () => {
|
|
|
205
206
|
});
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The tool page's half of the marker. The finding comes from the MCP proxy,
|
|
211
|
+
* which is the only thing that loads a connected server's tools; what this
|
|
212
|
+
* endpoint decides is WHO sees it — the people who may write the `.tool`,
|
|
213
|
+
* and nobody else.
|
|
214
|
+
*/
|
|
215
|
+
describe('a tool of this server hidden for an invalid schema', () => {
|
|
216
|
+
const HIDDEN = {
|
|
217
|
+
manual: 'remote',
|
|
218
|
+
name: 'remote_srv_query',
|
|
219
|
+
path: '/properties/value/anyOf/0/required/0',
|
|
220
|
+
reason: 'must be a string',
|
|
221
|
+
marker:
|
|
222
|
+
'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const writer = (allowed: boolean): IAccessControl =>
|
|
226
|
+
({
|
|
227
|
+
canRead: async () => true,
|
|
228
|
+
canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
229
|
+
canWrite: async () => allowed,
|
|
230
|
+
}) as unknown as IAccessControl;
|
|
231
|
+
|
|
232
|
+
test('is reported to a caller who may write the tool', async () => {
|
|
233
|
+
await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
|
|
234
|
+
const service = svc(writer(true));
|
|
235
|
+
service.setHiddenTools({ hiddenFor: (manual) => (manual === 'remote' ? [HIDDEN] : []) });
|
|
236
|
+
expect((await service.getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([HIDDEN]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('is withheld from a caller who may only read it', async () => {
|
|
240
|
+
await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
|
|
241
|
+
const service = svc(writer(false));
|
|
242
|
+
service.setHiddenTools({ hiddenFor: () => [HIDDEN] });
|
|
243
|
+
expect((await service.getDetail('reader@x.eu', 'remote'))?.hiddenTools).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('is empty, and costs no write check, when the server is healthy', async () => {
|
|
247
|
+
await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
|
|
248
|
+
const canWrite = vi.fn(async () => true);
|
|
249
|
+
const service = svc({
|
|
250
|
+
canRead: async () => true,
|
|
251
|
+
canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
252
|
+
canWrite,
|
|
253
|
+
} as unknown as IAccessControl);
|
|
254
|
+
service.setHiddenTools({ hiddenFor: () => [] });
|
|
255
|
+
expect((await service.getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([]);
|
|
256
|
+
expect(canWrite).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('is empty when no MCP surface has loaded a server yet', async () => {
|
|
260
|
+
await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
|
|
261
|
+
expect((await svc().getDetail('owner@x.eu', 'remote'))?.hiddenTools).toEqual([]);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
208
265
|
test('description is null (not undefined) when the file declares none', async () => {
|
|
209
266
|
await withTool('bare.tool', JSON.stringify({ name: 'bare', type: 'inline', tools: [] }));
|
|
210
267
|
const detail = await svc().getDetail('user@x.eu', 'bare');
|
|
@@ -9,6 +9,7 @@ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
|
9
9
|
import { registerToolManualsTools } from '../tool-manuals.tools.js';
|
|
10
10
|
import type { IToolManualService } from '../tool-manuals.contract.js';
|
|
11
11
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
12
|
+
import type { HiddenTool, HiddenToolSource } from '../../../shared/hidden-tools.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `list_tool_setup` MUST respect the same access controls as every other tool
|
|
@@ -31,7 +32,10 @@ const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
|
|
|
31
32
|
const CATALOG = [
|
|
32
33
|
{
|
|
33
34
|
slug: 'weather',
|
|
34
|
-
|
|
35
|
+
// The catalog NAME differs from the slug on purpose: the hidden-tool
|
|
36
|
+
// source is keyed by name, and a lookup keyed by slug would pass a
|
|
37
|
+
// fixture where the two are the same string.
|
|
38
|
+
name: 'Weather Service',
|
|
35
39
|
path: 'Plugins/weather.tool',
|
|
36
40
|
type: 'mcp' as const,
|
|
37
41
|
setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
|
|
@@ -93,7 +97,16 @@ const externalApiKeyService = {
|
|
|
93
97
|
t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
|
|
94
98
|
} as never;
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
/** What the proxy's schema check found for `weather`, when a test wires one in. */
|
|
101
|
+
const HIDDEN_WEATHER_TOOL: HiddenTool = {
|
|
102
|
+
manual: 'Weather Service',
|
|
103
|
+
name: 'weather_srv_forecast',
|
|
104
|
+
path: '/properties/value/anyOf/0/required/0',
|
|
105
|
+
reason: 'must be a string',
|
|
106
|
+
marker: 'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
async function start(hiddenTools?: HiddenToolSource): Promise<string> {
|
|
97
110
|
const registry = new ToolRegistry();
|
|
98
111
|
const internalToken = new InternalTokenService({ secret: 'test-secret' });
|
|
99
112
|
const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
|
|
@@ -116,6 +129,7 @@ async function start(): Promise<string> {
|
|
|
116
129
|
accessControl,
|
|
117
130
|
variableStatus: { statusFor },
|
|
118
131
|
kb: testKbContext(),
|
|
132
|
+
hiddenTools,
|
|
119
133
|
});
|
|
120
134
|
app.use('/api', router);
|
|
121
135
|
|
|
@@ -158,8 +172,10 @@ describe('list_tool_setup — access controls resolved for the caller', () => {
|
|
|
158
172
|
// Bob can't read billing — it must be absent, not just canWrite=false.
|
|
159
173
|
expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
|
|
160
174
|
expect(bob.tools[0].canWrite).toBe(false);
|
|
161
|
-
// Status was resolved for BOB's user id, not leaked from Alice's
|
|
162
|
-
|
|
175
|
+
// Status was resolved for BOB's user id, not leaked from Alice's — under
|
|
176
|
+
// the key the vault derives from the manual's NAME (`Weather Service`:
|
|
177
|
+
// the space becomes `_`, every `_` is doubled, then the variable).
|
|
178
|
+
expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['Weather__Service_SHARED_KEY']);
|
|
163
179
|
expect(bob.tools[0].variables[0].userConfigured).toBe(false);
|
|
164
180
|
});
|
|
165
181
|
|
|
@@ -182,6 +198,59 @@ describe('list_tool_setup — access controls resolved for the caller', () => {
|
|
|
182
198
|
expect(bob.invalid).toEqual([]);
|
|
183
199
|
});
|
|
184
200
|
|
|
201
|
+
it('marks a tool hidden for an invalid schema, to the caller who may manage the server', async () => {
|
|
202
|
+
const base = await start({
|
|
203
|
+
hiddenFor: (manual) => (manual === 'Weather Service' ? [HIDDEN_WEATHER_TOOL] : []),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
|
|
207
|
+
tools: { slug: string; hiddenTools: HiddenTool[] }[];
|
|
208
|
+
};
|
|
209
|
+
// Alice writes `weather`, so she is the one who can get the schema fixed.
|
|
210
|
+
expect(alice.tools.find((t) => t.slug === 'weather')!.hiddenTools).toEqual([HIDDEN_WEATHER_TOOL]);
|
|
211
|
+
// The marker names the tool, the place and the reason, in one sentence.
|
|
212
|
+
expect(alice.tools.find((t) => t.slug === 'weather')!.hiddenTools[0].marker).toBe(
|
|
213
|
+
'Hidden from agents: its schema is invalid at /properties/value/anyOf/0/required/0 (must be a string).',
|
|
214
|
+
);
|
|
215
|
+
// Nothing is wrong with `billing`, which Alice cannot write anyway.
|
|
216
|
+
expect(alice.tools.find((t) => t.slug === 'billing')!.hiddenTools).toEqual([]);
|
|
217
|
+
|
|
218
|
+
// Bob READS `weather` and cannot write it: the marker is not his to see.
|
|
219
|
+
// He has no way to fix the schema, and the hidden tool is simply not among
|
|
220
|
+
// the ones he can call.
|
|
221
|
+
const bob = (await (await callSetup(base, 'bevel_bob')).json()) as {
|
|
222
|
+
tools: { slug: string; hiddenTools: HiddenTool[] }[];
|
|
223
|
+
};
|
|
224
|
+
expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
|
|
225
|
+
expect(bob.tools[0].hiddenTools).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('reports no hidden tool on a deployment with no MCP surface at all — no source wired', async () => {
|
|
229
|
+
const base = await start(); // no source wired, as a deployment without the proxy
|
|
230
|
+
const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
|
|
231
|
+
tools: { slug: string; hiddenTools: HiddenTool[] }[];
|
|
232
|
+
};
|
|
233
|
+
for (const tool of alice.tools) expect(tool.hiddenTools).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('reports no hidden tool to a manager whose server is healthy, or has not loaded yet — source wired, nothing found', async () => {
|
|
237
|
+
// The distinct case from the one above: the proxy IS there and has nothing
|
|
238
|
+
// against `weather`, which Alice manages. `[]` is the finding, not the
|
|
239
|
+
// absence of a finder.
|
|
240
|
+
const hiddenFor = vi.fn((): HiddenTool[] => []);
|
|
241
|
+
const base = await start({ hiddenFor });
|
|
242
|
+
const alice = (await (await callSetup(base, 'bevel_alice')).json()) as {
|
|
243
|
+
tools: { slug: string; canWrite: boolean; hiddenTools: HiddenTool[] }[];
|
|
244
|
+
};
|
|
245
|
+
const weather = alice.tools.find((t) => t.slug === 'weather')!;
|
|
246
|
+
expect(weather.canWrite).toBe(true);
|
|
247
|
+
expect(weather.hiddenTools).toEqual([]);
|
|
248
|
+
// Asked by the manual's catalog NAME, which is how the proxy knows it —
|
|
249
|
+
// not by its slug — and the answer came from the source.
|
|
250
|
+
expect(hiddenFor).toHaveBeenCalledWith('Weather Service');
|
|
251
|
+
expect(hiddenFor).not.toHaveBeenCalledWith('weather');
|
|
252
|
+
});
|
|
253
|
+
|
|
185
254
|
it('rejects an unauthenticated call outright', async () => {
|
|
186
255
|
const base = await start();
|
|
187
256
|
const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CallTemplate } from '@utcp/sdk';
|
|
2
|
+
import type { HiddenTool, HiddenToolSource } from '../../shared/hidden-tools.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Tool manuals — user-authored `*.tool` files under `Plugins/` in the DEFAULT
|
|
@@ -329,10 +330,11 @@ export interface ToolCapability {
|
|
|
329
330
|
|
|
330
331
|
/**
|
|
331
332
|
* A single tool manual for the BROWSER tool page (`GET /api/tools/:slug`): the
|
|
332
|
-
* summary plus the
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
333
|
+
* summary plus the three human-facing fields the catalog listing has no use
|
|
334
|
+
* for — the description, the capabilities and the tools hidden for an invalid
|
|
335
|
+
* schema. Each is normalized to a definite empty (`null`, `[]`) rather than
|
|
336
|
+
* left optional — the page renders a definite "nothing here" state, so an
|
|
337
|
+
* absent field and an empty one are the same thing to it.
|
|
336
338
|
*/
|
|
337
339
|
export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'> {
|
|
338
340
|
description: string | null;
|
|
@@ -342,6 +344,16 @@ export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'>
|
|
|
342
344
|
* round-trip this endpoint deliberately does not make), so they report `[]`.
|
|
343
345
|
*/
|
|
344
346
|
capabilities: ToolCapability[];
|
|
347
|
+
/**
|
|
348
|
+
* Tools of this manual that Hexis keeps off every agent surface because
|
|
349
|
+
* their input schema is not valid JSON Schema as the server sent it, each
|
|
350
|
+
* with the place and the reason.
|
|
351
|
+
*
|
|
352
|
+
* Only for a caller who may WRITE this manual's file: the marker is for the
|
|
353
|
+
* people who manage the server, who are the only ones who can get the schema
|
|
354
|
+
* fixed. `[]` for everyone else, and for a server with nothing wrong.
|
|
355
|
+
*/
|
|
356
|
+
hiddenTools: HiddenTool[];
|
|
345
357
|
}
|
|
346
358
|
|
|
347
359
|
export interface IToolManualService {
|
|
@@ -374,6 +386,14 @@ export interface IToolManualService {
|
|
|
374
386
|
*/
|
|
375
387
|
getDetail(userEmail: string, slug: string): Promise<ToolManualDetail | null>;
|
|
376
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Wire where a hidden tool's schema finding comes from (the MCP proxy, which
|
|
391
|
+
* is constructed after this service — setter injection for the same reason
|
|
392
|
+
* `setMcpAuthDiscovery` is one). Without it `getDetail` reports no hidden
|
|
393
|
+
* tool, which is the honest answer for a deployment with no MCP surface.
|
|
394
|
+
*/
|
|
395
|
+
setHiddenTools(source: HiddenToolSource): void;
|
|
396
|
+
|
|
377
397
|
/**
|
|
378
398
|
* One line per manual the caller can read, each carrying the manual's
|
|
379
399
|
* identity AND a digest of the file it was parsed from — the input the
|
|
@@ -32,6 +32,7 @@ import { RESERVED_VARIABLE_NAMES, findReservedVariableRef } from '../../shared/v
|
|
|
32
32
|
import { extractFrontmatter, resolveDeclaredId, isValidId, dedupeById } from '../../shared/frontmatter-id.js';
|
|
33
33
|
import type { ITreeWalker } from '../../shared/fs.contract.js';
|
|
34
34
|
import { TtlCache } from '../../shared/ttl-cache.js';
|
|
35
|
+
import type { HiddenToolSource } from '../../shared/hidden-tools.js';
|
|
35
36
|
import {
|
|
36
37
|
utcpNamespacePrefix,
|
|
37
38
|
utcpNamespacedKey,
|
|
@@ -239,6 +240,8 @@ export type McpAuthDiscoveryResult =
|
|
|
239
240
|
export class ToolManualService implements IToolManualService {
|
|
240
241
|
private readonly cache: TtlCache<ScanResult>;
|
|
241
242
|
private mcpAuthDiscovery?: McpAuthDiscoveryPort;
|
|
243
|
+
/** Where a hidden tool's schema finding comes from; absent until wired. */
|
|
244
|
+
private hiddenTools?: HiddenToolSource;
|
|
242
245
|
/**
|
|
243
246
|
* The refused set as it was last written to the log, so the warnings are
|
|
244
247
|
* logged ONCE PER CHANGE rather than once per scan. A broken `.tool` sits
|
|
@@ -288,6 +291,10 @@ export class ToolManualService implements IToolManualService {
|
|
|
288
291
|
this.mcpAuthDiscovery = discovery;
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
setHiddenTools(source: HiddenToolSource): void {
|
|
295
|
+
this.hiddenTools = source;
|
|
296
|
+
}
|
|
297
|
+
|
|
291
298
|
invalidate(): void {
|
|
292
299
|
this.cache.invalidate();
|
|
293
300
|
this.inFlightScan = null;
|
|
@@ -317,10 +324,20 @@ export class ToolManualService implements IToolManualService {
|
|
|
317
324
|
// model, no second place for the access rules to drift.
|
|
318
325
|
const found = (await this.accessibleManuals(userEmail)).find((m) => m.slug === slug);
|
|
319
326
|
if (!found) return null;
|
|
327
|
+
// The marker is for whoever manages the server: the same per-file write
|
|
328
|
+
// verdict that gates setting the tool's shared secrets. A reader sees
|
|
329
|
+
// nothing of it — they can neither fix the schema nor do anything with the
|
|
330
|
+
// knowledge that one of the server's tools is off. The write check is
|
|
331
|
+
// asked only when there is something to show, so the healthy case (every
|
|
332
|
+
// tool of every server) costs no ACL round-trip.
|
|
333
|
+
const hidden = this.hiddenTools?.hiddenFor(found.name) ?? [];
|
|
334
|
+
const mayManage =
|
|
335
|
+
hidden.length > 0 && (await this.accessControl.canWrite(this.kb.defaultWorkspaceId(), userEmail, found.path));
|
|
320
336
|
return {
|
|
321
337
|
...toSummary(found),
|
|
322
338
|
description: found.description ?? null,
|
|
323
339
|
capabilities: capabilitiesOf(found),
|
|
340
|
+
hiddenTools: mayManage ? hidden : [],
|
|
324
341
|
};
|
|
325
342
|
}
|
|
326
343
|
|