@bevel-software/platform-core-backend 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +15 -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 +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- 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.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- 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 +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.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 +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +157 -54
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +97 -5
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +393 -114
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- 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 +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +172 -64
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +365 -19
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -68,7 +68,7 @@ describe('ToolManualService', () => {
|
|
|
68
68
|
|
|
69
69
|
beforeEach(async () => {
|
|
70
70
|
root = await mkdtemp(join(tmpdir(), 'tools-'));
|
|
71
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
71
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
72
72
|
await mkdir(tools, { recursive: true });
|
|
73
73
|
await writeFile(join(tools, 'weather.tool'), INLINE_TOOL);
|
|
74
74
|
await writeFile(join(tools, 'billing.tool'), HTTP_TOOL);
|
|
@@ -87,6 +87,16 @@ describe('ToolManualService', () => {
|
|
|
87
87
|
expect(list.map((m) => m.name)).toEqual(['weather']);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test('listAllSummaries returns every manual regardless of caller access', async () => {
|
|
91
|
+
// The group index counts a group's tools for people who cannot read them,
|
|
92
|
+
// so this surface must ignore the ACL that `listAccessible` applies.
|
|
93
|
+
const service = svc(denyBilling);
|
|
94
|
+
expect((await service.listAccessible('user@x.eu')).map((m) => m.name)).toEqual(['weather']);
|
|
95
|
+
const all = await service.listAllSummaries();
|
|
96
|
+
expect(all.map((m) => m.name).sort()).toEqual(['billing', 'weather']);
|
|
97
|
+
expect(all.map((m) => m.path).sort()).toEqual(['Groups/billing.tool', 'Groups/weather.tool']);
|
|
98
|
+
});
|
|
99
|
+
|
|
90
100
|
test('builds an inline manual as an http sub-manual call-template', async () => {
|
|
91
101
|
const templates = await svc().toManualCallTemplates('user@x.eu');
|
|
92
102
|
const inline = templates.find((t) => t.name === 'weather')!;
|
|
@@ -113,7 +123,7 @@ describe('ToolManualService', () => {
|
|
|
113
123
|
|
|
114
124
|
test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
|
|
115
125
|
root = await mkdtemp(join(tmpdir(), 'tools2-'));
|
|
116
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
126
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
117
127
|
await mkdir(tools, { recursive: true });
|
|
118
128
|
await writeFile(join(tools, 'my_cool-tool.tool'), JSON.stringify({ type: 'http', url: 'https://x.example.com/m' }));
|
|
119
129
|
const list = await svc().listAccessible('user@x.eu');
|
|
@@ -123,7 +133,7 @@ describe('ToolManualService', () => {
|
|
|
123
133
|
|
|
124
134
|
test('refuses a `.tool` whose manual name collides (no silent suffix)', async () => {
|
|
125
135
|
root = await mkdtemp(join(tmpdir(), 'tools3-'));
|
|
126
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
136
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
127
137
|
await mkdir(tools, { recursive: true });
|
|
128
138
|
// Both names normalize to `stripe`. The scanner keeps the first (sorted path
|
|
129
139
|
// order) and REFUSES the duplicate rather than renaming it `stripe2` — a
|
|
@@ -134,12 +144,12 @@ describe('ToolManualService', () => {
|
|
|
134
144
|
await writeFile(join(tools, 'b.tool'), JSON.stringify({ name: 'stripe!', type: 'http', url: 'https://b.example.com/m' }));
|
|
135
145
|
const list = await svc().listAccessible('user@x.eu');
|
|
136
146
|
expect(list.map((m) => m.name)).toEqual(['stripe']);
|
|
137
|
-
expect(list[0].path).toBe('
|
|
147
|
+
expect(list[0].path).toBe('Groups/a.tool');
|
|
138
148
|
});
|
|
139
149
|
|
|
140
150
|
test('parses `variables` scopes (default admin, explicit user/admin)', async () => {
|
|
141
151
|
root = await mkdtemp(join(tmpdir(), 'toolsv-'));
|
|
142
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
152
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
143
153
|
await mkdir(tools, { recursive: true });
|
|
144
154
|
await writeFile(
|
|
145
155
|
join(tools, 'weather.tool'),
|
|
@@ -164,7 +174,7 @@ describe('ToolManualService', () => {
|
|
|
164
174
|
|
|
165
175
|
test('skips a `.tool` with a malformed `variables` entry (never silently mis-scoped)', async () => {
|
|
166
176
|
root = await mkdtemp(join(tmpdir(), 'toolsvbad-'));
|
|
167
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
177
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
168
178
|
await mkdir(tools, { recursive: true });
|
|
169
179
|
await writeFile(
|
|
170
180
|
join(tools, 'weather.tool'),
|
|
@@ -175,7 +185,7 @@ describe('ToolManualService', () => {
|
|
|
175
185
|
|
|
176
186
|
test('scopeOfVariable: declared scope wins; undeclared/unknown default to admin', async () => {
|
|
177
187
|
root = await mkdtemp(join(tmpdir(), 'toolsscope-'));
|
|
178
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
188
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
179
189
|
await mkdir(tools, { recursive: true });
|
|
180
190
|
await writeFile(
|
|
181
191
|
join(tools, 'weather.tool'),
|
|
@@ -211,7 +221,7 @@ describe('ToolManualService', () => {
|
|
|
211
221
|
});
|
|
212
222
|
const writeOAuthTool = async (variable: unknown) => {
|
|
213
223
|
root = await mkdtemp(join(tmpdir(), 'toolsoauth-'));
|
|
214
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
224
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
215
225
|
await mkdir(tools, { recursive: true });
|
|
216
226
|
await writeFile(
|
|
217
227
|
join(tools, 'g.tool'),
|
|
@@ -258,7 +268,7 @@ describe('ToolManualService', () => {
|
|
|
258
268
|
|
|
259
269
|
test('parses `remote`: default true, explicit false, rejects non-boolean', async () => {
|
|
260
270
|
root = await mkdtemp(join(tmpdir(), 'toolsrem-'));
|
|
261
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
271
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
262
272
|
await mkdir(tools, { recursive: true });
|
|
263
273
|
await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
|
|
264
274
|
await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'mcp', url: 'https://x/m', remote: false }));
|
|
@@ -271,21 +281,21 @@ describe('ToolManualService', () => {
|
|
|
271
281
|
|
|
272
282
|
test('remoteOnly excludes local-only manuals; listLocalOnly returns them', async () => {
|
|
273
283
|
root = await mkdtemp(join(tmpdir(), 'toolsrem2-'));
|
|
274
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
284
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
275
285
|
await mkdir(tools, { recursive: true });
|
|
276
286
|
await writeFile(join(tools, 'pub.tool'), JSON.stringify({ name: 'pub', type: 'http', url: 'https://x/m' }));
|
|
277
287
|
await writeFile(join(tools, 'loc.tool'), JSON.stringify({ name: 'loc', type: 'http', url: 'https://x/m', remote: false }));
|
|
278
288
|
const s = svc();
|
|
279
289
|
expect((await s.toManualCallTemplates('user@x.eu', { remoteOnly: true })).map((t) => t.name).sort()).toEqual(['pub']);
|
|
280
290
|
expect((await s.toManualCallTemplates('user@x.eu')).map((t) => t.name).sort()).toEqual(['loc', 'pub']);
|
|
281
|
-
expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: '
|
|
291
|
+
expect(await s.listLocalOnly('user@x.eu')).toEqual([{ name: 'loc', path: 'Groups/loc.tool' }]);
|
|
282
292
|
// The local-only manual is still browsable/editable regardless of the remote flag.
|
|
283
293
|
expect((await s.listAccessible('user@x.eu')).map((m) => m.name).sort()).toEqual(['loc', 'pub']);
|
|
284
294
|
});
|
|
285
295
|
|
|
286
296
|
test('refuses a `.tool` that reproduces a reserved built-in namespace', async () => {
|
|
287
297
|
root = await mkdtemp(join(tmpdir(), 'toolsresv-'));
|
|
288
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
298
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
289
299
|
await mkdir(tools, { recursive: true });
|
|
290
300
|
// A `.tool` named exactly like the internal manual, or with the KB namespace as
|
|
291
301
|
// an explicit id, would resolve the loopback creds seeded under that namespace.
|
|
@@ -298,7 +308,7 @@ describe('ToolManualService', () => {
|
|
|
298
308
|
|
|
299
309
|
test('refuses a remote `.tool` whose url is a private/loopback/metadata host (SSRF)', async () => {
|
|
300
310
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf-'));
|
|
301
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
311
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
302
312
|
await mkdir(tools, { recursive: true });
|
|
303
313
|
await writeFile(join(tools, 'imds.tool'), JSON.stringify({ name: 'imds', type: 'http', url: 'http://169.254.169.254/latest/meta-data' }));
|
|
304
314
|
await writeFile(join(tools, 'loop.tool'), JSON.stringify({ name: 'loop', type: 'mcp', url: 'http://127.0.0.1:9000/mcp' }));
|
|
@@ -312,7 +322,7 @@ describe('ToolManualService', () => {
|
|
|
312
322
|
|
|
313
323
|
test('SSRF guard still applies when a private host is combined with a template token in the query', async () => {
|
|
314
324
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf3-'));
|
|
315
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
325
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
316
326
|
await mkdir(tools, { recursive: true });
|
|
317
327
|
// A `${VAR}` in the QUERY doesn't make the authority templated — the host is
|
|
318
328
|
// still the literal metadata endpoint, so the guard must reject it. Only a
|
|
@@ -325,7 +335,7 @@ describe('ToolManualService', () => {
|
|
|
325
335
|
|
|
326
336
|
test('refuses any `.tool` that references a platform-seeded variable (API_URL / CONNECTION_KEY)', async () => {
|
|
327
337
|
root = await mkdtemp(join(tmpdir(), 'toolsresvvar-'));
|
|
328
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
338
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
329
339
|
await mkdir(tools, { recursive: true });
|
|
330
340
|
// Both reference forms (`${VAR}` and `$VAR`), in any `.tool` type and any
|
|
331
341
|
// field — url query, header, an inline tool's call template. All refused at
|
|
@@ -348,7 +358,7 @@ describe('ToolManualService', () => {
|
|
|
348
358
|
|
|
349
359
|
test('refuses a `.tool` that references a NAMESPACED reserved variable (`<ns>_API_URL` / `<ns>_CONNECTION_KEY`)', async () => {
|
|
350
360
|
root = await mkdtemp(join(tmpdir(), 'toolsresvns-'));
|
|
351
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
361
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
352
362
|
await mkdir(tools, { recursive: true });
|
|
353
363
|
// The substitutor resolves a manual's variables under its UTCP namespace, so
|
|
354
364
|
// `${BEVEL_CONNECTION_KEY}` reaches the same seeded platform bearer the bare
|
|
@@ -376,7 +386,7 @@ describe('ToolManualService', () => {
|
|
|
376
386
|
|
|
377
387
|
test('refuses a `.tool` that declares a variable named API_URL or CONNECTION_KEY', async () => {
|
|
378
388
|
root = await mkdtemp(join(tmpdir(), 'toolsresvdecl-'));
|
|
379
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
389
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
380
390
|
await mkdir(tools, { recursive: true });
|
|
381
391
|
await writeFile(
|
|
382
392
|
join(tools, 'decl.tool'),
|
|
@@ -392,7 +402,7 @@ describe('ToolManualService', () => {
|
|
|
392
402
|
|
|
393
403
|
test('SSRF guard validates the literal host even when userinfo or port are templated', async () => {
|
|
394
404
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf4-'));
|
|
395
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
405
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
396
406
|
await mkdir(tools, { recursive: true });
|
|
397
407
|
// A `${VAR}` in the USERINFO or PORT doesn't make the hostname dynamic — the
|
|
398
408
|
// fetch still targets the literal metadata IP, so the guard must reject it.
|
|
@@ -417,7 +427,7 @@ describe('ToolManualService', () => {
|
|
|
417
427
|
|
|
418
428
|
test('SSRF guard exempts a local-only (remote:false) `.tool` and a templated url', async () => {
|
|
419
429
|
root = await mkdtemp(join(tmpdir(), 'toolsssrf2-'));
|
|
420
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
430
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
421
431
|
await mkdir(tools, { recursive: true });
|
|
422
432
|
// remote:false is never fetched server-side; a `${VAR}` url resolves at call time.
|
|
423
433
|
await writeFile(join(tools, 'local.tool'), JSON.stringify({ name: 'localmcp', type: 'mcp', url: 'http://localhost:3333/mcp', remote: false }));
|
|
@@ -437,7 +447,7 @@ describe('ToolManualService', () => {
|
|
|
437
447
|
|
|
438
448
|
test('parses `.tool` frontmatter: id is the manual name/namespace', async () => {
|
|
439
449
|
root = await mkdtemp(join(tmpdir(), 'toolsfm-'));
|
|
440
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
450
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
441
451
|
await mkdir(tools, { recursive: true });
|
|
442
452
|
// The tool IS the frontmatter: id + config in the one `---` block.
|
|
443
453
|
await writeFile(
|
|
@@ -451,7 +461,7 @@ describe('ToolManualService', () => {
|
|
|
451
461
|
|
|
452
462
|
test('snake_case id resolves scope via the UTCP doubled key', async () => {
|
|
453
463
|
root = await mkdtemp(join(tmpdir(), 'toolssnake-'));
|
|
454
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
464
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
455
465
|
await mkdir(tools, { recursive: true });
|
|
456
466
|
await writeFile(
|
|
457
467
|
join(tools, 't.tool'),
|
|
@@ -465,7 +475,7 @@ describe('ToolManualService', () => {
|
|
|
465
475
|
|
|
466
476
|
test('rejects a non-snake_case explicit id (file skipped)', async () => {
|
|
467
477
|
root = await mkdtemp(join(tmpdir(), 'toolsbadid-'));
|
|
468
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
478
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
469
479
|
await mkdir(tools, { recursive: true });
|
|
470
480
|
await writeFile(join(tools, 'x.tool'), '---\nid: My-Tool\ntype: http\nurl: https://x/m\n---\n');
|
|
471
481
|
expect(await svc().listAccessible('user@x.eu')).toHaveLength(0);
|
|
@@ -473,7 +483,7 @@ describe('ToolManualService', () => {
|
|
|
473
483
|
|
|
474
484
|
test('config after the closing fence is ignored (the tool is the frontmatter)', async () => {
|
|
475
485
|
root = await mkdtemp(join(tmpdir(), 'toolsnotes-'));
|
|
476
|
-
const tools = join(root, wsId, KB_DIR, '
|
|
486
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
477
487
|
await mkdir(tools, { recursive: true });
|
|
478
488
|
// `type`/`url` live in the fence; the JSON after it is free-form notes.
|
|
479
489
|
await writeFile(
|
|
@@ -485,6 +495,34 @@ describe('ToolManualService', () => {
|
|
|
485
495
|
expect(m.type).toBe('mcp');
|
|
486
496
|
});
|
|
487
497
|
|
|
498
|
+
test('parses `description` onto the descriptor, trimmed', async () => {
|
|
499
|
+
root = await mkdtemp(join(tmpdir(), 'toolsdesc-'));
|
|
500
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
501
|
+
await mkdir(tools, { recursive: true });
|
|
502
|
+
await writeFile(
|
|
503
|
+
join(tools, 'gh.tool'),
|
|
504
|
+
'---\nid: gh\ntype: mcp\nurl: https://mcp.example.com\ndescription: " Read and write issues. "\n---\n',
|
|
505
|
+
);
|
|
506
|
+
const [m] = await svc().listAccessible('user@x.eu');
|
|
507
|
+
expect(m.description).toBe('Read and write issues.');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('a malformed `description` is IGNORED — it never takes a working `.tool` offline', async () => {
|
|
511
|
+
// Every other field throws on a bad value, which skips the whole file. This
|
|
512
|
+
// one must not: `description` buys a reader one sentence, and no sentence is
|
|
513
|
+
// worth removing a working integration from the catalog.
|
|
514
|
+
root = await mkdtemp(join(tmpdir(), 'toolsdescbad-'));
|
|
515
|
+
const tools = join(root, wsId, KB_DIR, 'Groups');
|
|
516
|
+
await mkdir(tools, { recursive: true });
|
|
517
|
+
await writeFile(join(tools, 'num.tool'), JSON.stringify({ name: 'num', type: 'http', url: 'https://x/m', description: 42 }));
|
|
518
|
+
await writeFile(join(tools, 'obj.tool'), JSON.stringify({ name: 'obj', type: 'http', url: 'https://x/m', description: { a: 1 } }));
|
|
519
|
+
await writeFile(join(tools, 'nul.tool'), JSON.stringify({ name: 'nul', type: 'http', url: 'https://x/m', description: null }));
|
|
520
|
+
await writeFile(join(tools, 'blank.tool'), JSON.stringify({ name: 'blank', type: 'http', url: 'https://x/m', description: ' ' }));
|
|
521
|
+
const byName = new Map((await svc().listAccessible('user@x.eu')).map((m) => [m.name, m]));
|
|
522
|
+
expect([...byName.keys()].sort()).toEqual(['blank', 'nul', 'num', 'obj']); // all four still in the catalog
|
|
523
|
+
for (const m of byName.values()) expect(m.description).toBeUndefined();
|
|
524
|
+
});
|
|
525
|
+
|
|
488
526
|
test('preview validates an inline draft and reports its tools', async () => {
|
|
489
527
|
const preview = await svc().preview(INLINE_TOOL);
|
|
490
528
|
expect(preview.ok).toBe(true);
|
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
import type { Server as HttpServer } from 'node:http';
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
5
|
-
import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
|
|
6
|
-
import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
|
|
7
|
-
import { createToolContextResolver } from '../../tool-helpers/tool-context.js';
|
|
8
|
-
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
9
|
-
import { registerToolManualsTools } from '../tool-manuals.tools.js';
|
|
10
|
-
import type { IToolManualService } from '../tool-manuals.contract.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `list_tool_setup` MUST respect the same access controls as every other tool
|
|
14
|
-
* surface, resolved for THE CALLER:
|
|
15
|
-
*
|
|
16
|
-
* - the catalog is read-gated: a `.tool` the caller can't READ is absent
|
|
17
|
-
* (via `listAccessible(callerEmail)` — default-deny, per email);
|
|
18
|
-
* - `canWrite` is the PER-FILE verdict (`canWrite(email, path)`), not a role;
|
|
19
|
-
* - configuration status is the caller's own (`statusFor(callerUserId, …)`).
|
|
20
|
-
*
|
|
21
|
-
* Exercised over a real express app with the real tool auth + handler
|
|
22
|
-
* machinery, as two different users, so a caller-mixup (leaking another user's
|
|
23
|
-
* catalog or status) can't slip through the composition.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
const ALICE = { id: 'user-alice', email: 'alice@x.com', name: 'Alice' };
|
|
27
|
-
const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
|
|
28
|
-
|
|
29
|
-
// Alice reads both tools and may write weather; Bob reads only weather, writes nothing.
|
|
30
|
-
const CATALOG = [
|
|
31
|
-
{
|
|
32
|
-
slug: 'weather',
|
|
33
|
-
name: 'weather',
|
|
34
|
-
path: '
|
|
35
|
-
type: 'mcp' as const,
|
|
36
|
-
setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
|
|
37
|
-
variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
slug: 'billing',
|
|
41
|
-
name: 'billing',
|
|
42
|
-
path: '
|
|
43
|
-
type: 'http' as const,
|
|
44
|
-
variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
|
|
45
|
-
},
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
const toolManualService = {
|
|
49
|
-
listAccessible: vi.fn(async (email: string) =>
|
|
50
|
-
email === ALICE.email ? CATALOG : CATALOG.filter((m) => m.slug === 'weather'),
|
|
51
|
-
),
|
|
52
|
-
listLocalOnly: async () => [],
|
|
53
|
-
} as unknown as IToolManualService;
|
|
54
|
-
|
|
55
|
-
const accessControl = {
|
|
56
|
-
canWrite: vi.fn(
|
|
57
|
-
async (_ws: string, email: string, path: string) => email === ALICE.email && path === '
|
|
58
|
-
),
|
|
59
|
-
} as never;
|
|
60
|
-
|
|
61
|
-
const statusFor = vi.fn(async (userId: string, keys: string[]) =>
|
|
62
|
-
keys.map((key) => ({
|
|
63
|
-
key,
|
|
64
|
-
// Only Alice has configured anything — Bob's view must not inherit it.
|
|
65
|
-
adminConfigured: true,
|
|
66
|
-
userConfigured: userId === ALICE.id,
|
|
67
|
-
})),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
let httpServer: HttpServer | undefined;
|
|
71
|
-
|
|
72
|
-
const externalApiKeyService = {
|
|
73
|
-
looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
|
|
74
|
-
verifyAndLoadToken: async (t: string) =>
|
|
75
|
-
t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
|
|
76
|
-
} as never;
|
|
77
|
-
|
|
78
|
-
async function start(): Promise<string> {
|
|
79
|
-
const registry = new ToolRegistry();
|
|
80
|
-
const internalToken = new InternalTokenService({ secret: 'test-secret' });
|
|
81
|
-
const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
|
|
82
|
-
const resolve = createToolContextResolver({
|
|
83
|
-
authService: {
|
|
84
|
-
getUserById: async (id: string) => (id === ALICE.id ? ALICE : id === BOB.id ? BOB : null),
|
|
85
|
-
} as never,
|
|
86
|
-
workspaceService: { getWorkspacePath: async () => '/tmp/ws' } as never,
|
|
87
|
-
workflowService: {} as never,
|
|
88
|
-
events: {} as never,
|
|
89
|
-
kbDirName: 'knowledge-base',
|
|
90
|
-
creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} },
|
|
91
|
-
});
|
|
92
|
-
const toolHandler = createToolHandlerFactory(resolve);
|
|
93
|
-
|
|
94
|
-
const app = express();
|
|
95
|
-
app.use(express.json());
|
|
96
|
-
const router = express.Router();
|
|
97
|
-
registerToolManualsTools(registry, router, toolAuth, toolHandler, toolManualService, {
|
|
98
|
-
accessControl,
|
|
99
|
-
variableStatus: { statusFor },
|
|
100
|
-
});
|
|
101
|
-
app.use('/api', router);
|
|
102
|
-
|
|
103
|
-
httpServer = await new Promise<HttpServer>((r) => {
|
|
104
|
-
const s = app.listen(0, () => r(s));
|
|
105
|
-
});
|
|
106
|
-
const port = (httpServer.address() as { port: number }).port;
|
|
107
|
-
return `http://127.0.0.1:${port}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
afterEach(async () => {
|
|
111
|
-
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
112
|
-
httpServer = undefined;
|
|
113
|
-
vi.clearAllMocks();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
const callSetup = (base: string, bearer: string) =>
|
|
117
|
-
fetch(`${base}/api/agent/tools/list_tool_setup`, {
|
|
118
|
-
method: 'POST',
|
|
119
|
-
headers: { 'content-type': 'application/json', authorization: `Bearer ${bearer}` },
|
|
120
|
-
body: JSON.stringify({}),
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe('list_tool_setup — access controls resolved for the caller', () => {
|
|
124
|
-
it('shows each caller only the tools THEY can read, with THEIR per-file canWrite', async () => {
|
|
125
|
-
const base = await start();
|
|
126
|
-
|
|
127
|
-
const aliceRes = await callSetup(base, 'bevel_alice');
|
|
128
|
-
expect(aliceRes.status).toBe(200);
|
|
129
|
-
const alice = (await aliceRes.json()) as { tools: { slug: string; canWrite: boolean }[] };
|
|
130
|
-
expect(alice.tools.map((t) => t.slug).sort()).toEqual(['billing', 'weather']);
|
|
131
|
-
expect(alice.tools.find((t) => t.slug === 'weather')!.canWrite).toBe(true);
|
|
132
|
-
expect(alice.tools.find((t) => t.slug === 'billing')!.canWrite).toBe(false);
|
|
133
|
-
|
|
134
|
-
const bobRes = await callSetup(base, 'bevel_bob');
|
|
135
|
-
expect(bobRes.status).toBe(200);
|
|
136
|
-
const bob = (await bobRes.json()) as {
|
|
137
|
-
tools: { slug: string; canWrite: boolean; variables: { userConfigured: boolean }[] }[];
|
|
138
|
-
};
|
|
139
|
-
// Bob can't read billing — it must be absent, not just canWrite=false.
|
|
140
|
-
expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
|
|
141
|
-
expect(bob.tools[0].canWrite).toBe(false);
|
|
142
|
-
// Status was resolved for BOB's user id, not leaked from Alice's.
|
|
143
|
-
expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['weather_SHARED_KEY']);
|
|
144
|
-
expect(bob.tools[0].variables[0].userConfigured).toBe(false);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('rejects an unauthenticated call outright', async () => {
|
|
148
|
-
const base = await start();
|
|
149
|
-
const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
|
|
150
|
-
method: 'POST',
|
|
151
|
-
headers: { 'content-type': 'application/json' },
|
|
152
|
-
body: JSON.stringify({}),
|
|
153
|
-
});
|
|
154
|
-
expect(res.status).toBe(401);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
5
|
+
import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
|
|
6
|
+
import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
|
|
7
|
+
import { createToolContextResolver } from '../../tool-helpers/tool-context.js';
|
|
8
|
+
import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
9
|
+
import { registerToolManualsTools } from '../tool-manuals.tools.js';
|
|
10
|
+
import type { IToolManualService } from '../tool-manuals.contract.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `list_tool_setup` MUST respect the same access controls as every other tool
|
|
14
|
+
* surface, resolved for THE CALLER:
|
|
15
|
+
*
|
|
16
|
+
* - the catalog is read-gated: a `.tool` the caller can't READ is absent
|
|
17
|
+
* (via `listAccessible(callerEmail)` — default-deny, per email);
|
|
18
|
+
* - `canWrite` is the PER-FILE verdict (`canWrite(email, path)`), not a role;
|
|
19
|
+
* - configuration status is the caller's own (`statusFor(callerUserId, …)`).
|
|
20
|
+
*
|
|
21
|
+
* Exercised over a real express app with the real tool auth + handler
|
|
22
|
+
* machinery, as two different users, so a caller-mixup (leaking another user's
|
|
23
|
+
* catalog or status) can't slip through the composition.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALICE = { id: 'user-alice', email: 'alice@x.com', name: 'Alice' };
|
|
27
|
+
const BOB = { id: 'user-bob', email: 'bob@x.com', name: 'Bob' };
|
|
28
|
+
|
|
29
|
+
// Alice reads both tools and may write weather; Bob reads only weather, writes nothing.
|
|
30
|
+
const CATALOG = [
|
|
31
|
+
{
|
|
32
|
+
slug: 'weather',
|
|
33
|
+
name: 'weather',
|
|
34
|
+
path: 'Groups/weather.tool',
|
|
35
|
+
type: 'mcp' as const,
|
|
36
|
+
setup: { kind: 'oauth-manual' as const, reason: 'no dynamic client registration' },
|
|
37
|
+
variables: [{ name: 'SHARED_KEY', scope: 'admin' as const, label: null }],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
slug: 'billing',
|
|
41
|
+
name: 'billing',
|
|
42
|
+
path: 'Groups/billing.tool',
|
|
43
|
+
type: 'http' as const,
|
|
44
|
+
variables: [{ name: 'ORG_KEY', scope: 'admin' as const, label: null }],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const toolManualService = {
|
|
49
|
+
listAccessible: vi.fn(async (email: string) =>
|
|
50
|
+
email === ALICE.email ? CATALOG : CATALOG.filter((m) => m.slug === 'weather'),
|
|
51
|
+
),
|
|
52
|
+
listLocalOnly: async () => [],
|
|
53
|
+
} as unknown as IToolManualService;
|
|
54
|
+
|
|
55
|
+
const accessControl = {
|
|
56
|
+
canWrite: vi.fn(
|
|
57
|
+
async (_ws: string, email: string, path: string) => email === ALICE.email && path === 'Groups/weather.tool',
|
|
58
|
+
),
|
|
59
|
+
} as never;
|
|
60
|
+
|
|
61
|
+
const statusFor = vi.fn(async (userId: string, keys: string[]) =>
|
|
62
|
+
keys.map((key) => ({
|
|
63
|
+
key,
|
|
64
|
+
// Only Alice has configured anything — Bob's view must not inherit it.
|
|
65
|
+
adminConfigured: true,
|
|
66
|
+
userConfigured: userId === ALICE.id,
|
|
67
|
+
})),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
let httpServer: HttpServer | undefined;
|
|
71
|
+
|
|
72
|
+
const externalApiKeyService = {
|
|
73
|
+
looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
|
|
74
|
+
verifyAndLoadToken: async (t: string) =>
|
|
75
|
+
t === 'bevel_alice' ? { user: ALICE, tokenId: 'tok-a' } : t === 'bevel_bob' ? { user: BOB, tokenId: 'tok-b' } : null,
|
|
76
|
+
} as never;
|
|
77
|
+
|
|
78
|
+
async function start(): Promise<string> {
|
|
79
|
+
const registry = new ToolRegistry();
|
|
80
|
+
const internalToken = new InternalTokenService({ secret: 'test-secret' });
|
|
81
|
+
const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
|
|
82
|
+
const resolve = createToolContextResolver({
|
|
83
|
+
authService: {
|
|
84
|
+
getUserById: async (id: string) => (id === ALICE.id ? ALICE : id === BOB.id ? BOB : null),
|
|
85
|
+
} as never,
|
|
86
|
+
workspaceService: { getWorkspacePath: async () => '/tmp/ws' } as never,
|
|
87
|
+
workflowService: {} as never,
|
|
88
|
+
events: {} as never,
|
|
89
|
+
kbDirName: 'knowledge-base',
|
|
90
|
+
creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} },
|
|
91
|
+
});
|
|
92
|
+
const toolHandler = createToolHandlerFactory(resolve);
|
|
93
|
+
|
|
94
|
+
const app = express();
|
|
95
|
+
app.use(express.json());
|
|
96
|
+
const router = express.Router();
|
|
97
|
+
registerToolManualsTools(registry, router, toolAuth, toolHandler, toolManualService, {
|
|
98
|
+
accessControl,
|
|
99
|
+
variableStatus: { statusFor },
|
|
100
|
+
});
|
|
101
|
+
app.use('/api', router);
|
|
102
|
+
|
|
103
|
+
httpServer = await new Promise<HttpServer>((r) => {
|
|
104
|
+
const s = app.listen(0, () => r(s));
|
|
105
|
+
});
|
|
106
|
+
const port = (httpServer.address() as { port: number }).port;
|
|
107
|
+
return `http://127.0.0.1:${port}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
112
|
+
httpServer = undefined;
|
|
113
|
+
vi.clearAllMocks();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const callSetup = (base: string, bearer: string) =>
|
|
117
|
+
fetch(`${base}/api/agent/tools/list_tool_setup`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${bearer}` },
|
|
120
|
+
body: JSON.stringify({}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('list_tool_setup — access controls resolved for the caller', () => {
|
|
124
|
+
it('shows each caller only the tools THEY can read, with THEIR per-file canWrite', async () => {
|
|
125
|
+
const base = await start();
|
|
126
|
+
|
|
127
|
+
const aliceRes = await callSetup(base, 'bevel_alice');
|
|
128
|
+
expect(aliceRes.status).toBe(200);
|
|
129
|
+
const alice = (await aliceRes.json()) as { tools: { slug: string; canWrite: boolean }[] };
|
|
130
|
+
expect(alice.tools.map((t) => t.slug).sort()).toEqual(['billing', 'weather']);
|
|
131
|
+
expect(alice.tools.find((t) => t.slug === 'weather')!.canWrite).toBe(true);
|
|
132
|
+
expect(alice.tools.find((t) => t.slug === 'billing')!.canWrite).toBe(false);
|
|
133
|
+
|
|
134
|
+
const bobRes = await callSetup(base, 'bevel_bob');
|
|
135
|
+
expect(bobRes.status).toBe(200);
|
|
136
|
+
const bob = (await bobRes.json()) as {
|
|
137
|
+
tools: { slug: string; canWrite: boolean; variables: { userConfigured: boolean }[] }[];
|
|
138
|
+
};
|
|
139
|
+
// Bob can't read billing — it must be absent, not just canWrite=false.
|
|
140
|
+
expect(bob.tools.map((t) => t.slug)).toEqual(['weather']);
|
|
141
|
+
expect(bob.tools[0].canWrite).toBe(false);
|
|
142
|
+
// Status was resolved for BOB's user id, not leaked from Alice's.
|
|
143
|
+
expect(statusFor).toHaveBeenLastCalledWith(BOB.id, ['weather_SHARED_KEY']);
|
|
144
|
+
expect(bob.tools[0].variables[0].userConfigured).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects an unauthenticated call outright', async () => {
|
|
148
|
+
const base = await start();
|
|
149
|
+
const res = await fetch(`${base}/api/agent/tools/list_tool_setup`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({}),
|
|
153
|
+
});
|
|
154
|
+
expect(res.status).toBe(401);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CallTemplate } from '@utcp/sdk';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tool manuals — user-authored `*.tool` files under `
|
|
4
|
+
* Tool manuals — user-authored `*.tool` files under `Groups/` in the DEFAULT
|
|
5
5
|
* branch KB. Each is a UTCP *manual* (a pointer to where tools come from), not a
|
|
6
6
|
* flattened tool list. Access-controlled exactly like Skills (default-deny ACL
|
|
7
7
|
* on the `.tool` file path). The MCP/UTCP endpoint serves every manual a user
|
|
@@ -95,9 +95,16 @@ export interface ToolManualDescriptor {
|
|
|
95
95
|
* catalog; namespacing doubles underscores in vault keys (`utcpNamespacedKey`).
|
|
96
96
|
*/
|
|
97
97
|
name: string;
|
|
98
|
-
/** Repo-root-relative path of the `.tool` file (e.g. `
|
|
98
|
+
/** Repo-root-relative path of the `.tool` file (e.g. `Groups/Everyone/weather.tool`). */
|
|
99
99
|
path: string;
|
|
100
100
|
type: ToolManualType;
|
|
101
|
+
/**
|
|
102
|
+
* Optional one-line prose from the frontmatter, for humans browsing the
|
|
103
|
+
* catalog. PURELY COSMETIC — a malformed value is ignored rather than
|
|
104
|
+
* skipping the file (see `normalizeToolManual`), because a bad sentence must
|
|
105
|
+
* never take a working integration offline.
|
|
106
|
+
*/
|
|
107
|
+
description?: string;
|
|
101
108
|
/** For `http`/`mcp`: the endpoint URL (may contain `${VAR}` refs). */
|
|
102
109
|
url?: string;
|
|
103
110
|
httpMethod?: 'GET' | 'POST';
|
|
@@ -134,6 +141,8 @@ export interface ToolManualSummary {
|
|
|
134
141
|
name: string;
|
|
135
142
|
path: string;
|
|
136
143
|
type: ToolManualType;
|
|
144
|
+
/** The frontmatter `description`, when the file declares a usable one. */
|
|
145
|
+
description?: string;
|
|
137
146
|
/** Declared `${VAR}` scopes; empty when none declared. */
|
|
138
147
|
variables?: ToolVariable[];
|
|
139
148
|
/** `false` ⇒ local-only (not served to remote agents); absent/`true` ⇒ remote-capable. */
|
|
@@ -142,9 +151,48 @@ export interface ToolManualSummary {
|
|
|
142
151
|
setup?: ToolManualSetup;
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
/** One thing an `inline` manual's embedded tool list says the assistant can do. */
|
|
155
|
+
export interface ToolCapability {
|
|
156
|
+
name: string;
|
|
157
|
+
description: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A single tool manual for the BROWSER tool page (`GET /api/tools/:slug`): the
|
|
162
|
+
* summary plus the two human-facing fields the catalog listing has no use for.
|
|
163
|
+
* Both are normalized to `null` rather than left optional — the page renders a
|
|
164
|
+
* definite "nothing here" state, so an absent field and an empty one are the
|
|
165
|
+
* same thing to it.
|
|
166
|
+
*/
|
|
167
|
+
export interface ToolManualDetail extends Omit<ToolManualSummary, 'description'> {
|
|
168
|
+
description: string | null;
|
|
169
|
+
/**
|
|
170
|
+
* What the tool actually exposes, derived from an `inline` manual's embedded
|
|
171
|
+
* `tools`. `http`/`mcp` manuals resolve their tools at call time (a network
|
|
172
|
+
* round-trip this endpoint deliberately does not make), so they report `[]`.
|
|
173
|
+
*/
|
|
174
|
+
capabilities: ToolCapability[];
|
|
175
|
+
}
|
|
176
|
+
|
|
145
177
|
export interface IToolManualService {
|
|
146
178
|
/** The `.tool` manuals the user can read, as summaries. */
|
|
147
179
|
listAccessible(userEmail: string): Promise<ToolManualSummary[]>;
|
|
180
|
+
/**
|
|
181
|
+
* One readable `.tool` by slug, with its description + capabilities, for the
|
|
182
|
+
* browser tool page. `null` when no such slug exists OR the caller can't read
|
|
183
|
+
* it — the two are deliberately indistinguishable (fail-closed: a 404 must not
|
|
184
|
+
* confirm that a tool the caller can't see exists).
|
|
185
|
+
*/
|
|
186
|
+
getDetail(userEmail: string, slug: string): Promise<ToolManualDetail | null>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Every manual in the catalog, UNFILTERED by access — the mirror of
|
|
190
|
+
* `skillService.listSkills(undefined)`. For caller-INDEPENDENT counting only
|
|
191
|
+
* (the group index's "N tools", which a non-member is allowed to see as a
|
|
192
|
+
* number). Never surface a name, path or description from this to someone
|
|
193
|
+
* who cannot read the file; `listAccessible` is the surface for that.
|
|
194
|
+
*/
|
|
195
|
+
listAllSummaries(): Promise<ToolManualSummary[]>;
|
|
148
196
|
/**
|
|
149
197
|
* Validated UTCP manual call-templates for the user's accessible `.tool`s —
|
|
150
198
|
* one per file, ready for a UTCP client to `registerManual`. Each is validated
|