Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
dd076fd
chore: provde better examples for PluginPackageInfo constructor
tedhabeck Mar 5, 2026
aa371bc
enh: add PluginVersionInfo and PluginVersionRegistry models w/unit tests
tedhabeck Mar 5, 2026
e63a389
chore: lint fix
tedhabeck Mar 6, 2026
bf83716
chore: lint fix
tedhabeck Mar 6, 2026
5211afc
chore: unit tests and fixtures for plugin isolation via venv.
tedhabeck Mar 9, 2026
cced07d
enh: refactored the invoke_hook method in cpex/framework/isolated/cli…
tedhabeck Mar 10, 2026
4d2573d
chore: lint fix
tedhabeck Mar 10, 2026
d9f5aad
chore: updated unit test test_worker to get coverage to 97%.
tedhabeck Mar 10, 2026
73d0fb6
enh: The optimization eliminates the overhead of:
tedhabeck Mar 12, 2026
f2e70a6
fix: fail early plugin_path do not exist, computer .venv path automat…
tedhabeck Mar 12, 2026
fe76e49
chore: lint fix
tedhabeck Mar 13, 2026
ba50962
fix: use the system config file (PLUGINS_CONFIG_FILE) for syspath upd…
tedhabeck Mar 27, 2026
85d0fa2
chore: lint fix
tedhabeck Mar 27, 2026
e003ebe
chore: Validate plugin_dirs entries against an allowlist
tedhabeck Apr 7, 2026
5059d35
fix: remove hardcoded reference to plugins/config in the cpex/framewo…
tedhabeck Apr 9, 2026
f104b20
chore: lint fix
tedhabeck Apr 9, 2026
87f685e
enh: Add a maximum line length check before parsing. Add model tests…
tedhabeck Apr 10, 2026
1aea657
enh: model updates for PluginManifest, InstalledPluginInfo, and Insta…
tedhabeck Apr 15, 2026
ca260dc
enh: example values for git monorepo installation
tedhabeck Apr 15, 2026
e4d568f
enh: add ConfigSaver class to ConfigLoader
tedhabeck Apr 15, 2026
34aa40b
enh: plugin installation catalog
tedhabeck Apr 15, 2026
d827cf1
enh: add support to enable installation of a plugin using the cli fro…
tedhabeck Apr 15, 2026
93c6982
chore: doc string fix
tedhabeck Apr 16, 2026
e34aef4
chore: lint fix
tedhabeck Apr 16, 2026
7b04f83
chore: remove duplicate code
tedhabeck Apr 16, 2026
01f65b1
chore: test coverage improvements, remove duplicate code
tedhabeck Apr 16, 2026
a8ff432
chore: replace cargo with search for pyproject.
tedhabeck Apr 16, 2026
f3d3c1b
chore: lint fixes
tedhabeck Apr 16, 2026
60f3db0
enh: use pygithub apis rather than github rest apis, as they provide …
tedhabeck Apr 17, 2026
a727e1d
enh: add support for uninstall of plugin
tedhabeck Apr 17, 2026
a5a5342
chore: lint-fix
tedhabeck Apr 17, 2026
a74b3e4
fix: use the manifest from the local catalog to pull the kind value o…
tedhabeck Apr 17, 2026
dc893d9
fix: when installing a plugin via mono-repo or pipi, the cache_root w…
tedhabeck Apr 20, 2026
66b97d5
enh: enable package install from both pypi and test-pypi, fix: proper…
tedhabeck Apr 20, 2026
93db6ab
chore: stub for local installation
tedhabeck Apr 21, 2026
df1296f
chore: lint fix
tedhabeck Apr 21, 2026
13f775a
doc: add README for tools
tedhabeck Apr 21, 2026
0513024
enh: use cached repo object
tedhabeck Apr 21, 2026
5a022ae
chore: use rich emoji
tedhabeck Apr 21, 2026
ee70ddb
ptf: workaround for version mis-match of cpex dependency in plugin
tedhabeck Apr 23, 2026
63d2e8d
fix: download install targets to a temp folder to avoid installing to…
tedhabeck Apr 23, 2026
af48a1b
enh: pass the plugin install path to the update method, as isolated_v…
tedhabeck Apr 23, 2026
5c467f1
enh: the catalog now returns the install path for isolated_venv plugins
tedhabeck Apr 23, 2026
c0421cf
chore: unit test updates
tedhabeck Apr 23, 2026
aab87b4
misc: type fix
tedhabeck Apr 23, 2026
6d1d342
enh: the plugin self installs into the isolated_venv via requirements…
tedhabeck Apr 23, 2026
b1f2635
chore: increase coverage above 90%
tedhabeck Apr 23, 2026
96e334a
chore: update min_max_framework_version
tedhabeck Apr 27, 2026
edcc95a
enh: isolated venv cookiecutter update for install flow
tedhabeck Apr 27, 2026
dc20074
enh: catalog now properly persists all plugin-manifest*.yaml files
tedhabeck Apr 27, 2026
2561a5b
enh: upgrade pip before installing requirements
tedhabeck Apr 29, 2026
f9a791a
enh: allow the developer provided version registry values to persist,…
tedhabeck Apr 29, 2026
962d42d
enh: only update the catalog when not installing from test-pypi or p…
tedhabeck Apr 29, 2026
d872a2f
enh: refactor to reduce duplicate code, fix uninstall for isolated_ve…
tedhabeck May 1, 2026
132b9e1
chore: properly format info
tedhabeck May 1, 2026
1e222f1
chore: update README.md
tedhabeck May 1, 2026
a1aa65a
chore: Add a, "before you begin" section detailing the required .env …
tedhabeck May 1, 2026
972e4f3
fix: P0 fix — tarfile/zip path traversal
tedhabeck May 4, 2026
bcda708
enh: add remove_venv method to IsolatedVenvPlugin for uninstall cleanup.
tedhabeck May 4, 2026
75396a8
fix: priority 1 items
tedhabeck May 4, 2026
02789d6
fix: p2 item 17 search() case-insensitive match broken
tedhabeck May 4, 2026
07ebd45
chore: add tests for _ver method.
tedhabeck May 4, 2026
b2f20c8
fix: version registry update cleanup
tedhabeck May 4, 2026
33c3e95
chore: lint fix
tedhabeck May 4, 2026
8ebf07a
chore: add missing doc string, and tests for _ver method.
tedhabeck May 4, 2026
25aa1f2
chore: lint fix
tedhabeck May 4, 2026
387d0e7
fix: review p2 moderate 21 - list function now uses console.print
tedhabeck May 4, 2026
5df7451
chore: fix failing unit test, address non-atomic registry write.
tedhabeck May 4, 2026
e86ef12
chore: lint fix
tedhabeck May 4, 2026
84def91
chore: claude can't tell the difference between if "rc is False" and …
tedhabeck May 4, 2026
e42d99c
fix: cpex/framework/models.py — register_plugin (line 2392): replaced…
tedhabeck May 5, 2026
46cfdfb
fix: P2 Issue 20 Implementation Complete: Exit Code Handling
tedhabeck May 5, 2026
01dd563
chore: lint fix
tedhabeck May 5, 2026
3dc581a
fix: list function shadows built-in
tedhabeck May 5, 2026
8f464a8
chore: logic tweak
tedhabeck May 5, 2026
9b10a83
fix: p2 issue 23
tedhabeck May 5, 2026
43d1099
fix: P2 issue 19 error handling for corrupted JSON registry
tedhabeck May 5, 2026
2d07478
chore: lint fix
tedhabeck May 5, 2026
610f53c
fix: P2 issue 24 - Registry file path triplicated
tedhabeck May 5, 2026
e6c5fa4
chore: lint-fix
tedhabeck May 5, 2026
d1ed720
fix: P2 issue 25
tedhabeck May 6, 2026
ca2fd87
fix: update worker to call cpex.framework.utils.import_module rather …
tedhabeck May 6, 2026
61c6ea2
enh: add package integrity verification
tedhabeck May 6, 2026
5cd6a70
chore: lint fix
tedhabeck May 6, 2026
07a9ecd
chore: missed commit
tedhabeck May 6, 2026
c0f2835
fix: if the plugins/config.yaml plugins array is empty, initialize it…
tedhabeck May 8, 2026
f5bb2a7
chore: lint fix
tedhabeck May 8, 2026
a13f702
chore: version to '0.1.0 minimum'
tedhabeck May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,25 @@
# `allow`, `deny`
# PLUGINS_DEFAULT_HOOK_POLICY=allow

# Path to plugins folder
# PLUGINS_FOLDER=plugins
# Path to main plugins configuration file
# PLUGINS_CONFIG_FILE=plugins/config.yaml

### Plugin installation
# Comma Separated Values used by install with --type monorepo
# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins"

# registry path
# PLUGIN_REGISTRY_FOLDER=data

# Github API
# PLUGINS_GITHUB_API=api.github.com

# PLUGINS_GITHUB_TOKEN=<github token>
### end Plugin installation


# Logging level for plugin framework components
# PLUGINS_LOG_LEVEL=INFO

Expand Down Expand Up @@ -148,3 +164,15 @@
# PLUGINS_GRPC_SERVER_SSL_ENABLED=



### Package Integrity Verification
# Enable SHA256 hash verification for PyPI packages (default: True)
# When enabled, downloaded packages are verified against hashes from PyPI's JSON API
# Recommended: Keep enabled for security
# PLUGINS_VERIFY_PACKAGE_INTEGRITY=True

# Strict integrity mode (default: False)
# When True: Fail installation if package hashes are unavailable
# When False: Warn but continue if hashes are unavailable
# Recommended: False for development, True for production
# PLUGINS_STRICT_INTEGRITY_MODE=False
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,4 @@ db_path/
tmp/

.continue

plugin-catalog
38 changes: 21 additions & 17 deletions cpex/framework/isolated/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from cpex.framework.hooks.registry import get_hook_registry
from cpex.framework.isolated.venv_comm import VenvProcessCommunicator
from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult
from cpex.framework.utils import find_package_path

logger = logging.getLogger(__name__)

Expand All @@ -43,10 +44,10 @@ def __init__(self, config: PluginConfig, plugin_dirs) -> None:
# use the first plugin dir specified in the plugin configuration file.
path = Path(self.plugin_dirs[0]).resolve()
class_root = self.config.config.get("class_name").split(".")[0]
cache_root = path / class_root
self.plugin_path = cache_root
cache_root: Path = path / class_root
self.plugin_path: Path = cache_root
if not cache_root.exists():
raise RuntimeError(f"plugin path does not exist: {str(cache_root)}")
cache_root.mkdir(parents=True, exist_ok=True)
self.cache_dir: Path = cache_root / ".cpex" / "venv_cache"
self.cache_dir.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -217,21 +218,17 @@ async def initialize(self) -> None:
else:
requirements_file = Path(requirements_file_input)

# If it's a relative path, resolve it relative to plugin_path
if not requirements_file.is_absolute():
requirements_file = (self.plugin_path / requirements_file).resolve()
else:
# If absolute, resolve it to normalize
requirements_file = requirements_file.resolve()

# Validate that the resolved path is within plugin_path (security check)
# Try to find the package location where plugin-manifest.yaml resides
# Fall back to self.plugin_path if package is not installed (e.g., in tests)
try:
requirements_file.relative_to(self.plugin_path.resolve())
except ValueError as ve:
raise RuntimeError(
f"Invalid requirements_file path: {requirements_file_input}. "
f"Path must be within plugin directory: {self.plugin_path}"
) from ve
package_path = find_package_path(self.config.name)
logger.debug("Found installed package %s at %s", self.config.name, package_path)
except RuntimeError:
# Package not installed (e.g., in test environment), use plugin_path
package_path = self.plugin_path
logger.debug("Package %s not installed, using plugin_path: %s", self.config.name, package_path)

requirements_file = package_path / requirements_file_input

# Create venv with caching support
new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True)
Expand Down Expand Up @@ -339,3 +336,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu
except Exception as e:
logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name)
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e

def remove_venv(self):
"""
Remove the virtual environment associated with the plugin.
"""
shutil.rmtree(self.plugin_path.joinpath(".cpex"))
shutil.rmtree(self.plugin_path.joinpath(".venv"))
8 changes: 8 additions & 0 deletions cpex/framework/isolated/venv_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def _get_python_executable(self):

return str(python_exe)

def upgrade_pip(self) -> None:
"""Upgrade pip in the target venv."""
try:
subprocess.check_call([self.python_executable, "-m", "pip", "install", "--upgrade", "pip"])
except Exception as e:
raise RuntimeError("Failed to upgrade pip") from e

def install_requirements(self, requirements_file: str) -> None:
"""
Install Python requirements from a file in the target venv.
Expand All @@ -62,6 +69,7 @@ def install_requirements(self, requirements_file: str) -> None:
requirements_path = Path(requirements_file)
if requirements_path.exists():
try:
self.upgrade_pip()
subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file])
except Exception as e:
raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e
Expand Down
25 changes: 14 additions & 11 deletions cpex/framework/isolated/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS
from cpex.framework.manager import PluginExecutor
from cpex.framework.models import PluginConfig, PluginContext
from cpex.framework.utils import parse_class_name
from cpex.framework.utils import import_module, parse_class_name

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -115,7 +115,7 @@ async def process_task(task_data, tp: TaskProcessor):
hook_type = task_data.get(HOOK_TYPE)
cls_name: str = task_data.get("class_name")
mod_name, n_cls_name = parse_class_name(cls_name)
module: ModuleType = importlib.import_module(mod_name)
module: ModuleType = import_module(mod_name)
# cool, we found the module, and verified it implemented the hook type.
class_ = getattr(module, n_cls_name)
plugin_type = cast(Type[Plugin], class_)
Expand Down Expand Up @@ -162,7 +162,7 @@ async def main():
while True:
try:
# Read one line at a time
if tp.plugin_config:
if tp.plugin_config and "max_content_size" in tp.plugin_config:
line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size))
else:
# on the first read, the plugin_config has not yet been initialized so just read.
Expand Down Expand Up @@ -198,14 +198,17 @@ async def main():
serialized_response = json.dumps(serializable_response)
# Send response back to parent (one line per response)
if tp.plugin_config:
if len(serialized_response) > tp.plugin_config.max_content_size:
logger.error("Serialized response exceeds max content size")
error_response = {
"status": "error",
"message": "Serialized response exceeds max content size",
"request_id": request_id,
}
serialized_response = json.dumps(error_response)
# workaround until cpex is updated beyond dev11
# cpex is a dependency of the plugin and as such it's PluginConfig does not contain the max_content_size yet.
if "max_content_size" in tp.plugin_config:
if len(serialized_response) > tp.plugin_config.max_content_size:
logger.error("Serialized response exceeds max content size")
error_response = {
"status": "error",
"message": "Serialized response exceeds max content size",
"request_id": request_id,
}
serialized_response = json.dumps(error_response)
print(serialized_response, flush=True)

except json.JSONDecodeError as e:
Expand Down
19 changes: 19 additions & 0 deletions cpex/framework/loader/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,22 @@ def load_config(config: str, use_jinja: bool = True) -> Config:
except FileNotFoundError:
# Graceful fallback for tests and minimal environments without plugin config
return Config(plugins=[], plugin_dirs=[])


class ConfigSaver:
"""
A configuration saver
"""

@staticmethod
def save_config(config: Config, config_path: str) -> None:
"""
Save the supplied configuration data to the filesystem
"""
try:
updated_content = yaml.safe_dump(config.model_dump(mode="json"), default_flow_style=False)
with open(os.path.normpath(config_path), "w", encoding="utf-8") as file:
file.write(updated_content)
file.flush()
except OSError as ose:
raise RuntimeError(f"Error saving PluginConfig to {config_path}") from ose
Loading
Loading