@namzu/sdk 3.1.0 → 3.2.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/CHANGELOG.md +177 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
- package/dist/advisory/__tests__/consultation-context.test.js +124 -0
- package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
- package/dist/advisory/context.d.ts +25 -0
- package/dist/advisory/context.d.ts.map +1 -1
- package/dist/advisory/context.js +18 -0
- package/dist/advisory/context.js.map +1 -1
- package/dist/advisory/executor.d.ts.map +1 -1
- package/dist/advisory/executor.js +25 -3
- package/dist/advisory/executor.js.map +1 -1
- package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
- package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/context-reducer.test.js +197 -0
- package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
- package/dist/compaction/factory.d.ts +7 -0
- package/dist/compaction/factory.d.ts.map +1 -1
- package/dist/compaction/factory.js +7 -0
- package/dist/compaction/factory.js.map +1 -1
- package/dist/compaction/index.d.ts +2 -0
- package/dist/compaction/index.d.ts.map +1 -1
- package/dist/compaction/index.js +1 -0
- package/dist/compaction/index.js.map +1 -1
- package/dist/compaction/interface.d.ts +13 -0
- package/dist/compaction/interface.d.ts.map +1 -1
- package/dist/compaction/managers/null.d.ts +3 -0
- package/dist/compaction/managers/null.d.ts.map +1 -1
- package/dist/compaction/managers/null.js +3 -0
- package/dist/compaction/managers/null.js.map +1 -1
- package/dist/compaction/managers/slidingWindow.d.ts +6 -0
- package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
- package/dist/compaction/managers/slidingWindow.js +6 -0
- package/dist/compaction/managers/slidingWindow.js.map +1 -1
- package/dist/compaction/managers/structured.d.ts +10 -0
- package/dist/compaction/managers/structured.d.ts.map +1 -1
- package/dist/compaction/managers/structured.js +10 -0
- package/dist/compaction/managers/structured.js.map +1 -1
- package/dist/compaction/reducer.d.ts +86 -0
- package/dist/compaction/reducer.d.ts.map +1 -0
- package/dist/compaction/reducer.js +77 -0
- package/dist/compaction/reducer.js.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
- package/dist/connector/builtins/http.d.ts.map +1 -1
- package/dist/connector/builtins/http.js +24 -2
- package/dist/connector/builtins/http.js.map +1 -1
- package/dist/connector/builtins/http.test.js +18 -2
- package/dist/connector/builtins/http.test.js.map +1 -1
- package/dist/connector/index.d.ts +2 -2
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
- package/dist/connector/mcp/client.d.ts +52 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +86 -0
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/discovery.d.ts +12 -1
- package/dist/connector/mcp/discovery.d.ts.map +1 -1
- package/dist/connector/mcp/discovery.js +19 -4
- package/dist/connector/mcp/discovery.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +2 -2
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +1 -1
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/server.d.ts +42 -1
- package/dist/connector/mcp/server.d.ts.map +1 -1
- package/dist/connector/mcp/server.js +77 -4
- package/dist/connector/mcp/server.js.map +1 -1
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
- package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
- package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
- package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
- package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
- package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
- package/dist/plugin/lifecycle.d.ts +41 -0
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +29 -1
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/loader.d.ts +39 -3
- package/dist/plugin/loader.d.ts.map +1 -1
- package/dist/plugin/loader.js +37 -4
- package/dist/plugin/loader.js.map +1 -1
- package/dist/public-runtime.d.ts +4 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -2
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -2
- package/dist/public-types.d.ts.map +1 -1
- package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
- package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
- package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
- package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
- package/dist/rag/ingestion.d.ts.map +1 -1
- package/dist/rag/ingestion.js +1 -0
- package/dist/rag/ingestion.js.map +1 -1
- package/dist/rag/retriever.d.ts.map +1 -1
- package/dist/rag/retriever.js +2 -0
- package/dist/rag/retriever.js.map +1 -1
- package/dist/rag/vector-store.d.ts.map +1 -1
- package/dist/rag/vector-store.js +6 -0
- package/dist/rag/vector-store.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +113 -109
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
- package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
- package/dist/runtime/query/index.d.ts +10 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +26 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +96 -34
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +95 -5
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/resume-run.d.ts +70 -0
- package/dist/runtime/query/resume-run.d.ts.map +1 -0
- package/dist/runtime/query/resume-run.js +46 -0
- package/dist/runtime/query/resume-run.js.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.js +124 -0
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
- package/dist/tools/advisory/index.js +1 -1
- package/dist/tools/advisory/index.js.map +1 -1
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +13 -1
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
- package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
- package/dist/tools/coordinator/plan-dependencies.js +148 -0
- package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
- package/dist/types/agent/supervisor.d.ts +15 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/connector/core.d.ts +34 -0
- package/dist/types/connector/core.d.ts.map +1 -1
- package/dist/types/connector/definition.d.ts +10 -0
- package/dist/types/connector/definition.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +13 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/rag/retrieval.d.ts +16 -0
- package/dist/types/rag/retrieval.d.ts.map +1 -1
- package/dist/types/rag/storage.d.ts +9 -0
- package/dist/types/rag/storage.d.ts.map +1 -1
- package/dist/types/rag/vector.d.ts +11 -0
- package/dist/types/rag/vector.d.ts.map +1 -1
- package/dist/types/router/task-router.d.ts +19 -0
- package/dist/types/router/task-router.d.ts.map +1 -1
- package/dist/types/run/prepare-step.d.ts +56 -3
- package/dist/types/run/prepare-step.d.ts.map +1 -1
- package/dist/types/toolset/index.d.ts +22 -0
- package/dist/types/toolset/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/advisory/__tests__/consultation-context.test.ts +191 -0
- package/src/advisory/context.ts +32 -0
- package/src/advisory/executor.ts +30 -3
- package/src/compaction/__tests__/context-reducer.test.ts +239 -0
- package/src/compaction/factory.ts +7 -0
- package/src/compaction/index.ts +8 -0
- package/src/compaction/interface.ts +13 -0
- package/src/compaction/managers/null.ts +3 -0
- package/src/compaction/managers/slidingWindow.ts +6 -0
- package/src/compaction/managers/structured.ts +10 -0
- package/src/compaction/reducer.ts +154 -0
- package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
- package/src/connector/builtins/http.test.ts +28 -2
- package/src/connector/builtins/http.ts +26 -2
- package/src/connector/index.ts +6 -2
- package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
- package/src/connector/mcp/client.ts +95 -0
- package/src/connector/mcp/discovery.ts +19 -4
- package/src/connector/mcp/index.ts +6 -2
- package/src/connector/mcp/server.ts +101 -3
- package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
- package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
- package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
- package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
- package/src/plugin/lifecycle.ts +57 -1
- package/src/plugin/loader.ts +57 -3
- package/src/public-runtime.ts +8 -0
- package/src/public-types.ts +5 -0
- package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
- package/src/rag/ingestion.ts +1 -0
- package/src/rag/retriever.ts +2 -0
- package/src/rag/vector-store.ts +5 -0
- package/src/registry/tool/execute.ts +123 -119
- package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
- package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
- package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
- package/src/runtime/query/index.ts +39 -0
- package/src/runtime/query/iteration/index.ts +103 -34
- package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
- package/src/runtime/query/iteration/phases/compaction.ts +106 -5
- package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
- package/src/runtime/query/iteration/phases/context.ts +11 -0
- package/src/runtime/query/resume-run.ts +93 -0
- package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
- package/src/telemetry/__tests__/span-closure.test.ts +153 -0
- package/src/tools/advisory/index.ts +1 -1
- package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
- package/src/tools/coordinator/index.ts +14 -1
- package/src/tools/coordinator/plan-dependencies.ts +175 -0
- package/src/types/agent/supervisor.ts +15 -0
- package/src/types/connector/core.ts +34 -0
- package/src/types/connector/definition.ts +10 -0
- package/src/types/connector/mcp.ts +14 -0
- package/src/types/rag/retrieval.ts +16 -0
- package/src/types/rag/storage.ts +9 -0
- package/src/types/rag/vector.ts +11 -0
- package/src/types/router/task-router.ts +19 -0
- package/src/types/run/prepare-step.ts +58 -3
- package/src/types/toolset/index.ts +22 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { MCPToolDefinition } from '../../types/connector/index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A plugin's MCP server decided what entered the agent's tool registry.
|
|
10
|
+
*
|
|
11
|
+
* `MCPToolDiscovery` has held the admission boundary — a per-server
|
|
12
|
+
* allow/deny policy, and detection for a server whose tool set changes
|
|
13
|
+
* between discoveries — since it was written, and nothing outside its own
|
|
14
|
+
* tests ever constructed one. `PluginLifecycleManager.attachMCPServer`, the
|
|
15
|
+
* only code in the tree that connects a real MCP server, called
|
|
16
|
+
* `client.listTools()` and registered whatever came back.
|
|
17
|
+
*
|
|
18
|
+
* So least privilege was inverted at the one place it mattered: the remote
|
|
19
|
+
* side chose. Tools land as `deferred` and a run's `allowedTools` filters the
|
|
20
|
+
* model-visible catalogue, so this was not "arbitrary tools reach the model
|
|
21
|
+
* immediately" — but the check written for exactly this was not on the path.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let advertised: MCPToolDefinition[] = []
|
|
25
|
+
let clientCount = 0
|
|
26
|
+
|
|
27
|
+
vi.mock('../../connector/mcp/client.js', () => ({
|
|
28
|
+
MCPClient: class {
|
|
29
|
+
readonly id: string
|
|
30
|
+
private serverName: string
|
|
31
|
+
constructor(config: { serverName: string }) {
|
|
32
|
+
this.id = `client_${clientCount++}`
|
|
33
|
+
this.serverName = config.serverName
|
|
34
|
+
}
|
|
35
|
+
async connect(): Promise<void> {}
|
|
36
|
+
async disconnect(): Promise<void> {}
|
|
37
|
+
isConnected(): boolean {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
getState() {
|
|
41
|
+
return { serverName: this.serverName }
|
|
42
|
+
}
|
|
43
|
+
async listTools(): Promise<MCPToolDefinition[]> {
|
|
44
|
+
return advertised
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
function tool(name: string): MCPToolDefinition {
|
|
50
|
+
return { name, description: name, inputSchema: { type: 'object', properties: {} } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const log = {
|
|
54
|
+
debug: vi.fn(),
|
|
55
|
+
info: vi.fn(),
|
|
56
|
+
warn: vi.fn(),
|
|
57
|
+
error: vi.fn(),
|
|
58
|
+
child: () => log,
|
|
59
|
+
} as never
|
|
60
|
+
|
|
61
|
+
let root: string
|
|
62
|
+
|
|
63
|
+
interface Harness {
|
|
64
|
+
readonly registered: string[]
|
|
65
|
+
readonly manager: import('../lifecycle.js').PluginLifecycleManager
|
|
66
|
+
enable(name: string): Promise<import('../../types/ids/index.js').PluginId>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function harness(config: Record<string, unknown> = {}): Promise<Harness> {
|
|
70
|
+
const { PluginLifecycleManager } = await import('../lifecycle.js')
|
|
71
|
+
const { PluginRegistry } = await import('../../registry/plugin/index.js')
|
|
72
|
+
|
|
73
|
+
const registered: string[] = []
|
|
74
|
+
const toolRegistry = {
|
|
75
|
+
register: (definition: { name: string }) => registered.push(definition.name),
|
|
76
|
+
unregister: () => undefined,
|
|
77
|
+
get: () => undefined,
|
|
78
|
+
} as never
|
|
79
|
+
|
|
80
|
+
const manager = new PluginLifecycleManager({
|
|
81
|
+
pluginRegistry: new PluginRegistry(),
|
|
82
|
+
toolRegistry,
|
|
83
|
+
log,
|
|
84
|
+
...config,
|
|
85
|
+
} as never)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
registered,
|
|
89
|
+
manager,
|
|
90
|
+
enable: async (name: string) => {
|
|
91
|
+
const dir = join(root, name)
|
|
92
|
+
await mkdir(dir, { recursive: true })
|
|
93
|
+
await writeFile(
|
|
94
|
+
join(dir, 'plugin.json'),
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
name,
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
description: 'MCP server plugin fixture',
|
|
99
|
+
mcpServers: [{ name: 'files', command: 'node', args: ['server.js'] }],
|
|
100
|
+
}),
|
|
101
|
+
'utf-8',
|
|
102
|
+
)
|
|
103
|
+
const plugin = await manager.install(dir, 'project')
|
|
104
|
+
await manager.enable(plugin.id)
|
|
105
|
+
return plugin.id
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
root = await mkdtemp(join(tmpdir(), 'namzu-mcp-admit-'))
|
|
112
|
+
clientCount = 0
|
|
113
|
+
advertised = [tool('read_file'), tool('write_file'), tool('delete_everything')]
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
await rm(root, { recursive: true, force: true })
|
|
118
|
+
vi.clearAllMocks()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('what a plugin server advertises is not what the registry gets', () => {
|
|
122
|
+
it('admits only the tools an allowlist names', async () => {
|
|
123
|
+
const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file', 'write_file'] } } })
|
|
124
|
+
|
|
125
|
+
await h.enable('srv')
|
|
126
|
+
|
|
127
|
+
expect(h.registered.some((n) => n.endsWith('read_file'))).toBe(true)
|
|
128
|
+
expect(h.registered.some((n) => n.endsWith('write_file'))).toBe(true)
|
|
129
|
+
// The one the server offered and the host never agreed to.
|
|
130
|
+
expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('refuses a denied tool through the wildcard policy', async () => {
|
|
134
|
+
const h = await harness({ mcpToolPolicies: { '*': { deny: ['delete_everything'] } } })
|
|
135
|
+
|
|
136
|
+
await h.enable('srv')
|
|
137
|
+
|
|
138
|
+
expect(h.registered).toHaveLength(2)
|
|
139
|
+
expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('still admits everything when no policy is configured', async () => {
|
|
143
|
+
// Adding a boundary must not turn every existing host's working plugin
|
|
144
|
+
// into a broken one.
|
|
145
|
+
const h = await harness()
|
|
146
|
+
|
|
147
|
+
await h.enable('srv')
|
|
148
|
+
|
|
149
|
+
expect(h.registered).toHaveLength(3)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('namespaces what it admits, exactly as before', async () => {
|
|
153
|
+
const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file'] } } })
|
|
154
|
+
|
|
155
|
+
await h.enable('srv')
|
|
156
|
+
|
|
157
|
+
expect(h.registered[0]).toContain('mcp__files__read_file')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('a server that changes its tools between connections is reported', () => {
|
|
162
|
+
it('says nothing the first time, having nothing to compare against', async () => {
|
|
163
|
+
const onMCPToolDrift = vi.fn()
|
|
164
|
+
const h = await harness({ onMCPToolDrift })
|
|
165
|
+
|
|
166
|
+
await h.enable('srv')
|
|
167
|
+
|
|
168
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reports a tool the server grew after it was first approved', async () => {
|
|
172
|
+
const onMCPToolDrift = vi.fn()
|
|
173
|
+
const h = await harness({ onMCPToolDrift })
|
|
174
|
+
const first = await h.enable('srv-a')
|
|
175
|
+
|
|
176
|
+
// The rug pull: advertise something benign while a host is deciding,
|
|
177
|
+
// swap it afterwards. A NEW client connects to the SAME server, which
|
|
178
|
+
// is why the remembered set is keyed by server name — keyed by client
|
|
179
|
+
// id, as it was, every connection was its own first and this could
|
|
180
|
+
// never fire.
|
|
181
|
+
await h.manager.disable(first)
|
|
182
|
+
advertised = [...advertised, tool('exfiltrate')]
|
|
183
|
+
await h.enable('srv-b')
|
|
184
|
+
|
|
185
|
+
expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
|
|
186
|
+
expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({
|
|
187
|
+
serverName: 'files',
|
|
188
|
+
drift: { added: ['exfiltrate'] },
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('reports a tool whose shape changed under the same name', async () => {
|
|
193
|
+
const onMCPToolDrift = vi.fn()
|
|
194
|
+
const h = await harness({ onMCPToolDrift })
|
|
195
|
+
const first = await h.enable('srv-a')
|
|
196
|
+
|
|
197
|
+
await h.manager.disable(first)
|
|
198
|
+
// Same name, different input shape — the version a name-only check
|
|
199
|
+
// misses entirely.
|
|
200
|
+
advertised = [
|
|
201
|
+
{
|
|
202
|
+
name: 'read_file',
|
|
203
|
+
description: 'read a file',
|
|
204
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
|
|
205
|
+
},
|
|
206
|
+
tool('write_file'),
|
|
207
|
+
tool('delete_everything'),
|
|
208
|
+
]
|
|
209
|
+
await h.enable('srv-b')
|
|
210
|
+
|
|
211
|
+
expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
|
|
212
|
+
expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({ drift: { changed: ['read_file'] } })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('says nothing when the server offers exactly what it offered before', async () => {
|
|
216
|
+
const onMCPToolDrift = vi.fn()
|
|
217
|
+
const h = await harness({ onMCPToolDrift })
|
|
218
|
+
const first = await h.enable('srv-a')
|
|
219
|
+
|
|
220
|
+
await h.manager.disable(first)
|
|
221
|
+
await h.enable('srv-b')
|
|
222
|
+
|
|
223
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('compares what was ADMITTED, not what was advertised', async () => {
|
|
227
|
+
const onMCPToolDrift = vi.fn()
|
|
228
|
+
const h = await harness({
|
|
229
|
+
mcpToolPolicies: { files: { allow: ['read_file'] } },
|
|
230
|
+
onMCPToolDrift,
|
|
231
|
+
})
|
|
232
|
+
const first = await h.enable('srv-a')
|
|
233
|
+
|
|
234
|
+
await h.manager.disable(first)
|
|
235
|
+
// A tool the policy refuses either way. Reporting drift for it would
|
|
236
|
+
// train a host to ignore the warning that matters.
|
|
237
|
+
advertised = [...advertised, tool('another_denied_one')]
|
|
238
|
+
await h.enable('srv-b')
|
|
239
|
+
|
|
240
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
241
|
+
})
|
|
242
|
+
})
|
package/src/plugin/lifecycle.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { join } from 'node:path'
|
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
3
|
import { mcpToolToToolDefinition } from '../connector/mcp/adapter.js'
|
|
4
4
|
import { MCPClient } from '../connector/mcp/client.js'
|
|
5
|
+
import { MCPToolDiscovery } from '../connector/mcp/discovery.js'
|
|
6
|
+
import type { MCPToolDiscoveryOptions } from '../connector/mcp/discovery.js'
|
|
7
|
+
import type { MCPToolPolicy } from '../connector/mcp/policy.js'
|
|
5
8
|
import {
|
|
6
9
|
DEFAULT_HOOK_PRIORITY,
|
|
7
10
|
HOOK_TIMEOUT_MS,
|
|
@@ -37,6 +40,32 @@ export interface PluginLifecycleManagerConfig {
|
|
|
37
40
|
toolRegistry: ToolRegistryContract
|
|
38
41
|
log: Logger
|
|
39
42
|
hookTimeoutMs?: number
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What each MCP server a plugin brings is allowed to contribute, keyed by
|
|
46
|
+
* server name. `'*'` covers every server not named explicitly.
|
|
47
|
+
*
|
|
48
|
+
* Absent admits everything, which is what this path did unconditionally
|
|
49
|
+
* until now. `MCPToolDiscovery` has held this boundary — and the drift
|
|
50
|
+
* detection below — since it was written, and nothing outside its own
|
|
51
|
+
* tests ever constructed one: `attachMCPServer` called `listTools()`
|
|
52
|
+
* directly and registered whatever came back. So the least-privilege
|
|
53
|
+
* check existed, was tested, was exported, and was not on the path any
|
|
54
|
+
* real MCP server takes.
|
|
55
|
+
*/
|
|
56
|
+
mcpToolPolicies?: Readonly<Record<string, MCPToolPolicy>>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when a server's admitted tool set differs from the last time it
|
|
60
|
+
* was discovered.
|
|
61
|
+
*
|
|
62
|
+
* Reported rather than blocked, for the reason `MCPToolDiscovery`
|
|
63
|
+
* already gives: a development server legitimately changes between runs,
|
|
64
|
+
* while a production one changing mid-session is the rug pull — advertise
|
|
65
|
+
* something benign at approval time, swap it afterwards. Only the host
|
|
66
|
+
* knows which it is looking at.
|
|
67
|
+
*/
|
|
68
|
+
onMCPToolDrift?: MCPToolDiscoveryOptions['onDrift']
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
export class PluginLifecycleManager {
|
|
@@ -55,12 +84,32 @@ export class PluginLifecycleManager {
|
|
|
55
84
|
private pluginContributions: Map<PluginId, PluginContributionRecord> = new Map()
|
|
56
85
|
private hookTimeoutMs: number
|
|
57
86
|
private log: Logger
|
|
87
|
+
/**
|
|
88
|
+
* The admission boundary for everything a plugin's MCP servers advertise.
|
|
89
|
+
*
|
|
90
|
+
* One instance for the whole manager rather than one per plugin, and that
|
|
91
|
+
* is the part that matters: the instance is what remembers each server's
|
|
92
|
+
* previously admitted tool set, so a server that changes between one
|
|
93
|
+
* plugin being disabled and the next being enabled is still noticed. A
|
|
94
|
+
* per-plugin instance would forget on every teardown, which is precisely
|
|
95
|
+
* the window a rug pull uses.
|
|
96
|
+
*
|
|
97
|
+
* Its client list is deliberately left empty — `discoverFrom` takes the
|
|
98
|
+
* client directly, so registering it here would be bookkeeping nothing
|
|
99
|
+
* reads.
|
|
100
|
+
*/
|
|
101
|
+
private mcpDiscovery: MCPToolDiscovery
|
|
58
102
|
|
|
59
103
|
constructor(config: PluginLifecycleManagerConfig) {
|
|
60
104
|
this.pluginRegistry = config.pluginRegistry
|
|
61
105
|
this.toolRegistry = config.toolRegistry
|
|
62
106
|
this.hookTimeoutMs = config.hookTimeoutMs ?? HOOK_TIMEOUT_MS
|
|
63
107
|
this.log = config.log.child({ component: 'PluginLifecycleManager' })
|
|
108
|
+
this.mcpDiscovery = new MCPToolDiscovery([], {
|
|
109
|
+
...(config.mcpToolPolicies ? { policies: config.mcpToolPolicies } : {}),
|
|
110
|
+
...(config.onMCPToolDrift ? { onDrift: config.onMCPToolDrift } : {}),
|
|
111
|
+
logger: this.log,
|
|
112
|
+
})
|
|
64
113
|
}
|
|
65
114
|
|
|
66
115
|
/**
|
|
@@ -257,7 +306,14 @@ export class PluginLifecycleManager {
|
|
|
257
306
|
await client.connect()
|
|
258
307
|
contributions.mcpClients.push(client)
|
|
259
308
|
|
|
260
|
-
|
|
309
|
+
// Through the boundary, not around it. This used to call
|
|
310
|
+
// `client.listTools()` and register everything the server answered
|
|
311
|
+
// with, so the remote side decided what entered the agent's registry
|
|
312
|
+
// — least privilege inverted. `discoverFrom` applies the per-server
|
|
313
|
+
// allow/deny policy and reports a tool set that changed since the
|
|
314
|
+
// last discovery.
|
|
315
|
+
const discovered = await this.mcpDiscovery.discoverFrom(client)
|
|
316
|
+
const mcpTools = discovered.map((d) => d.tool)
|
|
261
317
|
for (const mcpTool of mcpTools) {
|
|
262
318
|
const baseDef = mcpToolToToolDefinition(mcpTool, client, config.name)
|
|
263
319
|
// Double-underscore between serverName and toolName so that e.g.
|
package/src/plugin/loader.ts
CHANGED
|
@@ -88,23 +88,77 @@ export function assertEnableable(manifest: PluginManifest): void {
|
|
|
88
88
|
)
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Where a plugin came from. Project plugins ship with the repo; user plugins live in the home directory. */
|
|
92
|
+
export type PluginScope = 'project' | 'user'
|
|
93
|
+
|
|
91
94
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
95
|
+
* The discovery half of {@link PluginRuntimeConfig}.
|
|
96
|
+
*
|
|
97
|
+
* Structural rather than an import so this module keeps depending on nothing
|
|
98
|
+
* — a parsed `PluginRuntimeConfig` satisfies it as-is.
|
|
99
|
+
*/
|
|
100
|
+
export interface PluginDiscoveryOptions {
|
|
101
|
+
/** Whether the plugin runtime is on at all. `false` discovers nothing. */
|
|
102
|
+
readonly enabled?: boolean
|
|
103
|
+
/** Whether to scan for plugins. `false` discovers nothing. */
|
|
104
|
+
readonly autoDiscovery?: boolean
|
|
105
|
+
/** Which locations may be scanned. Absent means both. */
|
|
106
|
+
readonly allowedScopes?: readonly PluginScope[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find plugin directories, in the locations the caller permits.
|
|
111
|
+
*
|
|
112
|
+
* `allowedScopes` is a trust boundary, not a filter applied afterwards. A
|
|
113
|
+
* plugin is arbitrary code with hooks into tool execution, and the two scopes
|
|
114
|
+
* are not equally trusted: project plugins are reviewable in the repo the
|
|
115
|
+
* agent is working on, while user plugins come from a home directory the
|
|
116
|
+
* repo's reviewers never see. `['project']` is how a host says the second
|
|
117
|
+
* kind is not allowed to run here.
|
|
118
|
+
*
|
|
119
|
+
* It was previously declared and unread — `PluginRuntimeConfig` carried
|
|
120
|
+
* `enabled`, `autoDiscovery` and `allowedScopes`, nothing anywhere consulted
|
|
121
|
+
* any of them, and this function scanned both locations unconditionally. A
|
|
122
|
+
* host who set `allowedScopes: ['project']` got user plugins anyway, from a
|
|
123
|
+
* setting that reads exactly like a boundary.
|
|
124
|
+
*
|
|
125
|
+
* A disallowed scope is NOT SCANNED rather than scanned and dropped.
|
|
126
|
+
* Filtering after the fact still reads the directory, which is both pointless
|
|
127
|
+
* work and a small disclosure — the returned count would tell a caller how
|
|
128
|
+
* many plugins live somewhere they said they would not look.
|
|
129
|
+
*
|
|
130
|
+
* Passing nothing keeps the old behaviour: both scopes, no gate. Existing
|
|
131
|
+
* callers are unaffected, and a caller who opts in gets what the config says.
|
|
94
132
|
*/
|
|
95
133
|
export async function discoverAllPluginDirs(
|
|
96
134
|
workingDirectory?: string,
|
|
135
|
+
options?: PluginDiscoveryOptions,
|
|
97
136
|
): Promise<{ project: string[]; user: string[] }> {
|
|
137
|
+
if (options?.enabled === false || options?.autoDiscovery === false) {
|
|
138
|
+
logger.debug('Plugin discovery skipped', {
|
|
139
|
+
enabled: options.enabled,
|
|
140
|
+
autoDiscovery: options.autoDiscovery,
|
|
141
|
+
})
|
|
142
|
+
return { project: [], user: [] }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const scopes = options?.allowedScopes
|
|
146
|
+
const mayScan = (scope: PluginScope): boolean => !scopes || scopes.includes(scope)
|
|
147
|
+
|
|
98
148
|
const projectDir = workingDirectory
|
|
99
149
|
? join(workingDirectory, PROJECT_PLUGIN_DIR)
|
|
100
150
|
: join(process.cwd(), PROJECT_PLUGIN_DIR)
|
|
101
151
|
const userDir = join(homedir(), USER_PLUGIN_DIR)
|
|
102
152
|
|
|
103
|
-
const [project, user] = await Promise.all([
|
|
153
|
+
const [project, user] = await Promise.all([
|
|
154
|
+
mayScan('project') ? discoverPlugins(projectDir) : Promise.resolve([]),
|
|
155
|
+
mayScan('user') ? discoverPlugins(userDir) : Promise.resolve([]),
|
|
156
|
+
])
|
|
104
157
|
|
|
105
158
|
logger.debug('Plugin discovery complete', {
|
|
106
159
|
projectCount: project.length,
|
|
107
160
|
userCount: user.length,
|
|
161
|
+
...(scopes ? { allowedScopes: scopes } : {}),
|
|
108
162
|
})
|
|
109
163
|
|
|
110
164
|
return { project, user }
|
package/src/public-runtime.ts
CHANGED
|
@@ -89,6 +89,12 @@ export type { ToolGrantKeys } from './runtime/query/tool-grants.js'
|
|
|
89
89
|
export { toWireRunStatus } from './contracts/run-status.js'
|
|
90
90
|
// Durable run state: the snapshot a different process picks a run up from.
|
|
91
91
|
export { captureRunState, loadRunState } from './runtime/query/run-state.js'
|
|
92
|
+
// …and the driver that joins the snapshot back to a running loop. Without
|
|
93
|
+
// it every host wrote the same wiring, and in practice none did.
|
|
94
|
+
export { resumeRun } from './runtime/query/resume-run.js'
|
|
95
|
+
export type { ResumeOutcome, ResumeRunParams } from './runtime/query/resume-run.js'
|
|
96
|
+
// The scope type was internal, so a host calling `loadRunState` could not
|
|
97
|
+
// name the argument it had to construct.
|
|
92
98
|
export type { RunStateScope } from './runtime/query/run-state.js'
|
|
93
99
|
export { prepareReplayState } from './runtime/query/replay/prepare.js'
|
|
94
100
|
export { listCheckpoints } from './runtime/query/replay/list.js'
|
|
@@ -261,6 +267,7 @@ export {
|
|
|
261
267
|
HybridExecutionContext,
|
|
262
268
|
LocalExecutionContext,
|
|
263
269
|
MCPClient,
|
|
270
|
+
MCPMethodNotFound,
|
|
264
271
|
MCPConnectorBridge,
|
|
265
272
|
MCPServer,
|
|
266
273
|
MCPToolDiscovery,
|
|
@@ -496,6 +503,7 @@ export {
|
|
|
496
503
|
lookupContextWindow,
|
|
497
504
|
resolveContextWindow,
|
|
498
505
|
createConversationManager,
|
|
506
|
+
createSlidingWindowReducer,
|
|
499
507
|
extractFromAssistantMessage,
|
|
500
508
|
extractFromToolCall,
|
|
501
509
|
extractFromToolResult,
|
package/src/public-types.ts
CHANGED
|
@@ -134,6 +134,7 @@ export type {
|
|
|
134
134
|
HybridExecutionContextOptions,
|
|
135
135
|
LocalExecutionContextOptions,
|
|
136
136
|
MCPServerResourceProvider,
|
|
137
|
+
MCPServerPromptProvider,
|
|
137
138
|
MCPServerToolProvider,
|
|
138
139
|
RemoteExecutionContextOptions,
|
|
139
140
|
TenantConnectorManagerConfig,
|
|
@@ -157,6 +158,10 @@ export type { ToolCallContext } from './verification/index.js'
|
|
|
157
158
|
export type { DiskSessionStoreConfig, LinkageView } from './store/session/index.js'
|
|
158
159
|
|
|
159
160
|
export type {
|
|
161
|
+
ContextReducer,
|
|
162
|
+
ContextReduction,
|
|
163
|
+
ContextReductionReason,
|
|
164
|
+
SlidingWindowOptions,
|
|
160
165
|
ConversationManager,
|
|
161
166
|
CompactionStrategy,
|
|
162
167
|
DanglingResult,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { EmbeddingProvider, TenantScope } from '../../types/rag/index.js'
|
|
4
|
+
import { DefaultIngestionPipeline } from '../ingestion.js'
|
|
5
|
+
import { DefaultRetriever } from '../retriever.js'
|
|
6
|
+
import { InMemoryVectorStore } from '../vector-store.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `TenantScope.namespace` and `KnowledgeBaseConfig.namespace` were declared
|
|
10
|
+
* from the start and neither reached storage. Ingestion copied
|
|
11
|
+
* `scope.tenantId` onto every chunk and dropped the namespace; the store
|
|
12
|
+
* filtered on tenant alone. So a partition a host asked for did not exist,
|
|
13
|
+
* and every namespace in a tenant saw every other one's documents.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Deterministic and content-derived, so similarity is stable across runs. */
|
|
17
|
+
const embedder: EmbeddingProvider = {
|
|
18
|
+
async embed(texts: string[]) {
|
|
19
|
+
return texts.map((t) => [t.length % 7, t.charCodeAt(0) % 5, 1])
|
|
20
|
+
},
|
|
21
|
+
async embedQuery(text: string) {
|
|
22
|
+
return [text.length % 7, text.charCodeAt(0) % 5, 1]
|
|
23
|
+
},
|
|
24
|
+
dimensions: 3,
|
|
25
|
+
model: 'test-embedder',
|
|
26
|
+
id: 'test-embedder',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tenant = 'tnt_one' as TenantScope['tenantId']
|
|
30
|
+
|
|
31
|
+
function scope(namespace?: string): TenantScope {
|
|
32
|
+
return { tenantId: tenant, ...(namespace !== undefined ? { namespace } : {}) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seed() {
|
|
36
|
+
const store = new InMemoryVectorStore()
|
|
37
|
+
const ingestion = new DefaultIngestionPipeline(store, embedder)
|
|
38
|
+
const kb = 'kb_one' as never
|
|
39
|
+
|
|
40
|
+
await ingestion.ingest('alpha partition content', {}, scope('alpha'), kb)
|
|
41
|
+
await ingestion.ingest('beta partition content', {}, scope('beta'), kb)
|
|
42
|
+
await ingestion.ingest('default partition content', {}, scope(), kb)
|
|
43
|
+
|
|
44
|
+
return { store, retriever: new DefaultRetriever(store, embedder), kb }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const texts = (r: { chunks: { chunk: { content: string } }[] }) =>
|
|
48
|
+
r.chunks.map((c) => c.chunk.content)
|
|
49
|
+
|
|
50
|
+
describe('a namespace partitions what a query can see', () => {
|
|
51
|
+
it('returns only its own partition', async () => {
|
|
52
|
+
const { retriever, kb } = await seed()
|
|
53
|
+
|
|
54
|
+
const found = await retriever.retrieve({ text: 'partition content' }, scope('alpha'), kb)
|
|
55
|
+
|
|
56
|
+
expect(texts(found)).toEqual(['alpha partition content'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not leak the other partition', async () => {
|
|
60
|
+
const { retriever, kb } = await seed()
|
|
61
|
+
|
|
62
|
+
const found = await retriever.retrieve({ text: 'partition content' }, scope('beta'), kb)
|
|
63
|
+
|
|
64
|
+
expect(texts(found).join()).not.toContain('alpha')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('treats an absent namespace as the default partition, not as no filter', async () => {
|
|
68
|
+
const { retriever, kb } = await seed()
|
|
69
|
+
|
|
70
|
+
const found = await retriever.retrieve({ text: 'partition content' }, scope(), kb)
|
|
71
|
+
|
|
72
|
+
// The whole point. Reading absence as "no filter" is how the boundary
|
|
73
|
+
// leaks: a caller who never asked for a namespace would see every
|
|
74
|
+
// namespaced chunk in the tenant.
|
|
75
|
+
expect(texts(found)).toEqual(['default partition content'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps tenants apart as it always did', async () => {
|
|
79
|
+
const { retriever, kb } = await seed()
|
|
80
|
+
|
|
81
|
+
const found = await retriever.retrieve(
|
|
82
|
+
{ text: 'partition content' },
|
|
83
|
+
{
|
|
84
|
+
tenantId: 'tnt_other' as TenantScope['tenantId'],
|
|
85
|
+
namespace: 'alpha',
|
|
86
|
+
},
|
|
87
|
+
kb,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(found.chunks).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('stamps the namespace onto the chunk at ingest', async () => {
|
|
94
|
+
const { store } = await seed()
|
|
95
|
+
|
|
96
|
+
const results = await store.search({
|
|
97
|
+
embedding: await embedder.embedQuery('partition content'),
|
|
98
|
+
topK: 10,
|
|
99
|
+
tenantId: tenant,
|
|
100
|
+
namespace: 'alpha',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Ingest is where it has to land — filtering at search against a value
|
|
104
|
+
// nothing writes returns zero rows, and "no results" reads as "nothing
|
|
105
|
+
// matched" rather than "this scope was never stored".
|
|
106
|
+
expect(results.every((r) => r.chunk.content.startsWith('alpha'))).toBe(true)
|
|
107
|
+
expect(results.length).toBeGreaterThan(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
package/src/rag/ingestion.ts
CHANGED
|
@@ -48,6 +48,7 @@ export class DefaultIngestionPipeline implements IngestionPipeline {
|
|
|
48
48
|
documentId,
|
|
49
49
|
knowledgeBaseId,
|
|
50
50
|
tenantId: scope.tenantId,
|
|
51
|
+
...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
|
|
51
52
|
content: cc.content,
|
|
52
53
|
index: cc.index,
|
|
53
54
|
tokenCount: estimateTokens(cc.content),
|
package/src/rag/retriever.ts
CHANGED
|
@@ -76,6 +76,7 @@ export class DefaultRetriever implements Retriever {
|
|
|
76
76
|
embedding,
|
|
77
77
|
topK: config.topK,
|
|
78
78
|
tenantId: scope.tenantId,
|
|
79
|
+
...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
|
|
79
80
|
knowledgeBaseId,
|
|
80
81
|
minScore: config.minScore,
|
|
81
82
|
})
|
|
@@ -92,6 +93,7 @@ export class DefaultRetriever implements Retriever {
|
|
|
92
93
|
embedding,
|
|
93
94
|
topK: config.topK * 2,
|
|
94
95
|
tenantId: scope.tenantId,
|
|
96
|
+
...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
|
|
95
97
|
knowledgeBaseId,
|
|
96
98
|
minScore: 0,
|
|
97
99
|
})
|
package/src/rag/vector-store.ts
CHANGED
|
@@ -20,6 +20,11 @@ export class InMemoryVectorStore implements VectorStore {
|
|
|
20
20
|
|
|
21
21
|
for (const chunk of this.chunks.values()) {
|
|
22
22
|
if (chunk.tenantId !== query.tenantId) continue
|
|
23
|
+
// Equality INCLUDING absence: an omitted namespace is the default
|
|
24
|
+
// partition, not the absence of a filter. Treating it as no filter
|
|
25
|
+
// is how the boundary leaks — a caller who never asked for a
|
|
26
|
+
// namespace would see every namespaced chunk in the tenant.
|
|
27
|
+
if (chunk.namespace !== query.namespace) continue
|
|
23
28
|
if (query.knowledgeBaseId && chunk.knowledgeBaseId !== query.knowledgeBaseId) continue
|
|
24
29
|
if (!chunk.embedding) continue
|
|
25
30
|
|