Skip to content

Commit 1336175

Browse files
authored
feat: plugin venv isolation (#6)
Signed-off-by: habeck <habeck@us.ibm.com>
1 parent 12bee6a commit 1336175

28 files changed

Lines changed: 5552 additions & 5 deletions

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,31 @@ server = ExternalPluginServer(plugins=[MyPlugin(config)])
325325
server.run()
326326
```
327327

328+
## Isolated plugins
329+
330+
Native plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. Plugin specific packages are automatically installed based on the contents of the supplied requirements_file.
331+
332+
```yaml
333+
- name: "test_plugin"
334+
kind: "isolated_venv"
335+
version: "0.1.0"
336+
hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"]
337+
tags: ["plugin"]
338+
mode: "sequential"
339+
priority: 150
340+
conditions:
341+
# Apply to specific tools/servers
342+
- server_ids: [] # Apply to all servers
343+
tenant_ids: [] # Apply to all tenants
344+
config:
345+
# Plugin config dict passed to the plugin constructor
346+
class_name: "test_plugin.plugin.TestPlugin"
347+
requirements_file: "requirements.txt"
348+
# essentially the plugin folder hosting the plugin relative to the project root
349+
script_path: "plugins"
350+
```
351+
352+
328353
## Project Status
329354

330355
CPEX is under active development as part of the [ContextForge](https://github.com/contextforge-org) ecosystem. The framework is designed to work across AI gateways, agent frameworks, LLM proxies, and tool servers.

cpex/framework/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# Model constants.
1414
# Specialized plugin types.
1515
EXTERNAL_PLUGIN_TYPE = "external"
16+
ISOLATED_VENV_PLUGIN_TYPE = "isolated_venv"
17+
1618

1719
# MCP related constants.
1820
PYTHON_SUFFIX = ".py"

cpex/framework/isolated/client.py

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./cpex/framework/isolated/client.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Ted Habeck
6+
7+
Isolated plugin client
8+
Module that contains plugin client code to serve venv isolated plugins.
9+
"""
10+
11+
import asyncio
12+
import functools
13+
import hashlib
14+
import json
15+
import logging
16+
import os
17+
import shutil
18+
import sys
19+
import venv
20+
from pathlib import Path
21+
22+
from typing_extensions import Any, Optional
23+
24+
from cpex.framework.base import Plugin
25+
from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME
26+
from cpex.framework.errors import PluginError, convert_exception_to_error
27+
from cpex.framework.hooks.registry import get_hook_registry
28+
from cpex.framework.isolated.venv_comm import VenvProcessCommunicator
29+
from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class IsolatedVenvPlugin(Plugin):
35+
"""IsolatedVenvPlugin class."""
36+
37+
def __init__(self, config: PluginConfig, plugin_dirs) -> None:
38+
"""Initialize the plugin's venv environment."""
39+
super().__init__(config)
40+
self.implementation = "Python"
41+
self.comm = None
42+
self.plugin_dirs = plugin_dirs
43+
# use the first plugin dir specified in the plugin configuration file.
44+
path = Path(self.plugin_dirs[0]).resolve()
45+
class_root = self.config.config.get("class_name").split(".")[0]
46+
cache_root = path / class_root
47+
self.plugin_path = cache_root
48+
if not cache_root.exists():
49+
raise RuntimeError(f"plugin path does not exist: {str(cache_root)}")
50+
self.cache_dir: Path = cache_root / ".cpex" / "venv_cache"
51+
self.cache_dir.mkdir(parents=True, exist_ok=True)
52+
53+
def _compute_requirements_hash(self, requirements_file: str) -> str:
54+
"""Compute SHA256 hash of requirements file content.
55+
56+
Args:
57+
requirements_file: Path to the requirements file
58+
59+
Returns:
60+
Hexadecimal hash string
61+
"""
62+
hasher = hashlib.sha256()
63+
req_path = Path(requirements_file)
64+
65+
if req_path.exists():
66+
with open(req_path, "rb") as f:
67+
hasher.update(f.read())
68+
else:
69+
# If no requirements file, use empty hash
70+
hasher.update(b"")
71+
72+
return hasher.hexdigest()
73+
74+
def _get_cache_metadata_path(self, venv_path: str) -> Path:
75+
"""Get the path to the cache metadata file.
76+
77+
Args:
78+
venv_path: Path to the virtual environment
79+
80+
Returns:
81+
Path to the metadata file
82+
"""
83+
venv_name = Path(venv_path).name
84+
return self.cache_dir / f"{venv_name}_metadata.json"
85+
86+
def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool:
87+
"""Check if cached venv is valid by comparing requirements hash.
88+
89+
Args:
90+
venv_path: Path to the virtual environment
91+
requirements_file: Path to the requirements file
92+
93+
Returns:
94+
True if cache is valid, False otherwise
95+
"""
96+
venv_path_obj = Path(venv_path)
97+
metadata_path = self._get_cache_metadata_path(venv_path)
98+
99+
# Check if venv directory exists
100+
if not venv_path_obj.exists():
101+
logger.debug("Venv path does not exist: %s", venv_path)
102+
return False
103+
104+
# Check if metadata file exists
105+
if not metadata_path.exists():
106+
logger.debug("Metadata file does not exist: %s", metadata_path)
107+
return False
108+
109+
try:
110+
# Load metadata
111+
with open(metadata_path, "r", encoding="utf8") as f:
112+
metadata = json.load(f)
113+
114+
# Compute current requirements hash
115+
current_hash = self._compute_requirements_hash(requirements_file)
116+
117+
# Compare hashes
118+
cached_hash = metadata.get("requirements_hash")
119+
if cached_hash != current_hash:
120+
logger.info("Requirements changed. Cached hash: %s, Current hash: %s", cached_hash, current_hash)
121+
return False
122+
123+
logger.info("Valid venv cache found for %s", venv_path)
124+
return True
125+
126+
except (json.JSONDecodeError, KeyError) as e:
127+
logger.warning("Error reading cache metadata: %s", str(e))
128+
return False
129+
130+
def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None:
131+
"""Save cache metadata for the venv.
132+
133+
Args:
134+
venv_path: Path to the virtual environment
135+
requirements_file: Path to the requirements file
136+
"""
137+
metadata_path = self._get_cache_metadata_path(venv_path)
138+
requirements_hash = self._compute_requirements_hash(requirements_file)
139+
140+
metadata = {
141+
"venv_path": str(Path(venv_path).resolve()),
142+
"requirements_file": str(Path(requirements_file).resolve()) if Path(requirements_file).exists() else None,
143+
"requirements_hash": requirements_hash,
144+
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
145+
}
146+
147+
with open(metadata_path, "w", encoding="utf8") as f:
148+
json.dump(metadata, f, indent=2)
149+
150+
logger.info("Saved cache metadata to %s", metadata_path)
151+
152+
async def create_venv(
153+
self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True
154+
) -> bool:
155+
"""Create a new venv environment with caching support.
156+
157+
Args:
158+
venv_path: Path where the virtual environment should be created
159+
requirements_file: Path to requirements file for cache validation
160+
use_cache: Whether to use cached venv if available
161+
"""
162+
venv_path_obj = Path(venv_path)
163+
164+
# Check if we can use cached venv
165+
if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file):
166+
logger.info("✓ Using cached virtual environment at: %s", venv_path_obj.resolve())
167+
return False
168+
169+
# If cache is invalid or not using cache, remove existing venv
170+
if venv_path_obj.exists():
171+
logger.info("Removing existing venv at %s", venv_path)
172+
shutil.rmtree(venv_path_obj)
173+
174+
# Check Python version
175+
python_version = sys.version_info
176+
logger.info(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}")
177+
178+
# Create the EnvBuilder with common options
179+
builder = venv.EnvBuilder(
180+
system_site_packages=False, # Don't include system site-packages
181+
clear=False, # Don't clear existing venv if it exists
182+
symlinks=True, # Use symlinks (recommended on Unix-like systems)
183+
upgrade=False, # Don't upgrade existing venv
184+
with_pip=True, # Install pip in the venv
185+
prompt=None, # Use default prompt (directory name)
186+
)
187+
188+
# Create the virtual environment
189+
logger.info(f"\nCreating virtual environment at: {venv_path_obj.resolve()}")
190+
try:
191+
builder.create(venv_path)
192+
logger.info("✓ Virtual environment created successfully!")
193+
logger.info("\nTo activate the virtual environment:")
194+
logger.info(f" source {venv_path}/bin/activate # On Unix/macOS")
195+
logger.info(f" {venv_path}\\Scripts\\activate # On Windows")
196+
return True
197+
except Exception as e:
198+
logger.error(f"✗ Error creating virtual environment: {e}")
199+
raise
200+
201+
# Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin()
202+
# The plugins/framework/manager.py class (PluginManager) loads and registers the plugin
203+
async def initialize(self) -> None:
204+
"""Initialize the plugin's venv environment with caching support."""
205+
# ensure the config is validated
206+
if not os.path.exists(self.plugin_path):
207+
raise FileNotFoundError(f"plugin path not found: {self.plugin_path}")
208+
209+
venv_path = self.plugin_path / ".venv"
210+
211+
# Prevent directory traversal: ensure requirements_file stays within plugin_path
212+
requirements_file_input = self.config.config["requirements_file"]
213+
214+
# Handle both relative and absolute paths
215+
if isinstance(requirements_file_input, Path):
216+
requirements_file = requirements_file_input
217+
else:
218+
requirements_file = Path(requirements_file_input)
219+
220+
# If it's a relative path, resolve it relative to plugin_path
221+
if not requirements_file.is_absolute():
222+
requirements_file = (self.plugin_path / requirements_file).resolve()
223+
else:
224+
# If absolute, resolve it to normalize
225+
requirements_file = requirements_file.resolve()
226+
227+
# Validate that the resolved path is within plugin_path (security check)
228+
try:
229+
requirements_file.relative_to(self.plugin_path.resolve())
230+
except ValueError as ve:
231+
raise RuntimeError(
232+
f"Invalid requirements_file path: {requirements_file_input}. "
233+
f"Path must be within plugin directory: {self.plugin_path}"
234+
) from ve
235+
236+
# Create venv with caching support
237+
new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True)
238+
239+
self.comm = VenvProcessCommunicator(venv_path)
240+
241+
# Only install requirements if venv was newly created or cache was invalid
242+
# Check if we need to install requirements
243+
if new_venv:
244+
logger.info("Installing requirements in venv")
245+
self.comm.install_requirements(requirements_file)
246+
# Save metadata after successful installation
247+
self._save_cache_metadata(venv_path, requirements_file)
248+
else:
249+
logger.info("Using cached venv, skipping requirements installation")
250+
251+
async def cleanup(self) -> None:
252+
"""Cleanup resources, including stopping the worker process."""
253+
if self.comm:
254+
logger.info("Stopping worker process for plugin '%s'", self.name)
255+
self.comm.stop_worker()
256+
self.comm = None
257+
258+
def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]:
259+
"""Validate hook type and communication channel.
260+
261+
Args:
262+
hook_type: The hook type to validate
263+
264+
Returns:
265+
The result type for the hook
266+
267+
Raises:
268+
PluginError: If validation fails
269+
"""
270+
registry = get_hook_registry()
271+
result_type = registry.get_result_type(hook_type)
272+
if not result_type:
273+
raise PluginError(
274+
error=PluginErrorModel(
275+
message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name
276+
)
277+
)
278+
279+
if not self.comm:
280+
raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name))
281+
282+
return result_type
283+
284+
def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> dict[str, Any]:
285+
"""Build task dictionary for hook invocation.
286+
287+
Args:
288+
hook_type: The hook type to invoke
289+
payload: The payload to send
290+
context: The context to send
291+
292+
Returns:
293+
Task dictionary ready for transmission
294+
"""
295+
# Cache config lookups
296+
class_name = self.config.config["class_name"]
297+
safe_config = self.config.get_safe_config()
298+
299+
# Serialize payload and context to ensure they are JSON-serializable
300+
serialized_payload = payload.model_dump(mode="json") if payload is not None else None
301+
serialized_context = context.model_dump(mode="json") if context is not None else None
302+
303+
return {
304+
"task_type": "load_and_run_hook",
305+
"plugin_dirs": self.plugin_dirs,
306+
"class_name": class_name,
307+
"config": safe_config,
308+
HOOK_TYPE: hook_type,
309+
PLUGIN_NAME: self.name,
310+
PAYLOAD: serialized_payload,
311+
CONTEXT: serialized_context,
312+
}
313+
314+
async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult:
315+
"""Invoke a plugin in the context of the active venv (self.comm)"""
316+
try:
317+
# Validate and get result type
318+
self._validate_hook_invocation(hook_type)
319+
320+
# Build and send task
321+
task_data = self._build_hook_task(hook_type, payload, context)
322+
loop = asyncio.get_event_loop()
323+
result_dict: dict[str, Any] = await loop.run_in_executor(
324+
None,
325+
functools.partial(
326+
self.comm.send_task,
327+
script_path="cpex/framework/isolated/worker.py",
328+
task_data=task_data,
329+
max_content_size=self.config.max_content_size,
330+
),
331+
)
332+
# Convert response to typed result
333+
registry = get_hook_registry()
334+
return registry.json_to_result(hook_type, result_dict)
335+
336+
except PluginError:
337+
logger.exception("Plugin error invoking hook '%s' for plugin '%s'", hook_type, self.name)
338+
raise
339+
except Exception as e:
340+
logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name)
341+
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e

0 commit comments

Comments
 (0)