Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ members = [
"crates/apl-delegator-biscuit",
"crates/apl-pii-scanner",
"crates/apl-audit-logger",
"crates/cpex-hosts-python",
"crates/apl-session-valkey",
"examples/go-demo/ffi",
]
Expand Down Expand Up @@ -88,6 +89,7 @@ rmp-serde = "1"
serde_bytes = "0.11"
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
sha2 = "0.10"

# Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked
# statically into host binaries, so its compiled size flows straight into
Expand Down
9 changes: 6 additions & 3 deletions cpex/framework/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,10 +441,13 @@ def __init__(self, hook: str, plugin_ref: PluginRef):
self._plugin_ref = plugin_ref
self._hook = hook

# Try convention-based lookup first (method name matches hook type)
# Try convention-based lookup first (method name matches hook type).
# For namespaced hooks like "cmf.tool_pre_invoke", also try the bare
# name "tool_pre_invoke" so convention-named methods are found.
bare_hook = hook.rsplit(".", 1)[-1] if "." in hook else hook
self._func: Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] | None = getattr(
plugin_ref.plugin, hook, None
)
) or getattr(plugin_ref.plugin, bare_hook, None)

# If not found by convention, scan for @hook decorated methods
if self._func is None:
Expand All @@ -455,7 +458,7 @@ def __init__(self, hook: str, plugin_ref: PluginRef):

# Check for @hook decorator metadata
metadata = get_hook_metadata(method)
if metadata and metadata.matches(hook):
if metadata and (metadata.matches(hook) or metadata.matches(bare_hook)):
self._func = method
break

Expand Down
34 changes: 25 additions & 9 deletions cpex/framework/isolated/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class TaskProcessor:

config_hash: str
module_path_hash: str
hook_ref: HookRef
plugin_ref: PluginRef
executor: PluginExecutor
plugin_config: PluginConfig | None = None

Expand All @@ -55,19 +55,27 @@ def compute_hash(self, json_config_or_module_path: str):

def initialize(
self,
hook_ref: HookRef,
plugin_ref: PluginRef,
executor: PluginExecutor,
json_config: str,
module_path: str,
plugin_config: PluginConfig,
):
"""Assign locals, and compute hashes."""
self.hook_ref = hook_ref
# self.hook_ref = hook_ref
self.plugin_ref = plugin_ref
self.executor = executor
self.config_hash = self.compute_hash(json_config_or_module_path=json_config)
self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path)
self.plugin_config = plugin_config

def get_hook_ref(self, hook_type: str) -> HookRef:
"""
make sure that the hook ref is not stale for the current task data.
"""
hook_ref = HookRef(hook_type, self.plugin_ref)
return hook_ref


def get_environment_info():
"""Get information about current Python environment."""
Expand Down Expand Up @@ -112,7 +120,6 @@ async def process_task(task_data, tp: TaskProcessor):
if tp.config_hash != tp.compute_hash(json_config):
# pull the resolved plugin path and only add the module path if it has the same root
config: PluginConfig = PluginConfig(**config_raw)
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 = import_module(mod_name)
Expand All @@ -121,24 +128,32 @@ async def process_task(task_data, tp: TaskProcessor):
plugin_type = cast(Type[Plugin], class_)
plugin = plugin_type(config)
await plugin.initialize()
# now invoke the hook
plugin_ref = PluginRef(plugin)
hook_ref = HookRef(hook_type, plugin_ref)
executor = PluginExecutor(None, 30)
tp.initialize(
hook_ref=hook_ref,
plugin_ref=plugin_ref,
executor=executor,
json_config=json_config,
module_path=json.dumps(resolved_paths),
plugin_config=config,
)
# retrieve the context
context = task_data.get("context")
hook_type = task_data.get(HOOK_TYPE)
# Normalize namespaced hook names (e.g. "cmf.tool_pre_invoke" →
# "tool_pre_invoke") so that convention-named plugin methods are found.
# Plugins that use @hook("cmf.tool_pre_invoke") decorators are handled
# by the bare-name fallback in HookRef; plugins that rely solely on
# method-name convention need the bare name here.
if hook_type and "." in hook_type:
bare = hook_type.rsplit(".", 1)[-1]
if not hasattr(tp.plugin_ref.plugin, hook_type) and hasattr(tp.plugin_ref.plugin, bare):
hook_type = bare
plugin_context = PluginContext(
state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata")
)
result = await tp.executor.execute_plugin(
hook_ref=tp.hook_ref,
hook_ref=tp.get_hook_ref(hook_type),
payload=task_data.get("payload"),
local_context=plugin_context,
violations_as_exceptions=False,
Expand Down Expand Up @@ -224,7 +239,8 @@ async def main():
error_response = {
"status": "error",
"message": f"Unexpected error: {str(e)}",
"request_id": "unknown",
# Use the request_id captured above when available so callers can demux.
"request_id": request_id if "request_id" in locals() else "unknown",
}
print(json.dumps(error_response), flush=True)

Expand Down
10 changes: 10 additions & 0 deletions crates/cpex-hosts-python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Generated by isolated_e2e test setup (write_requirements_txt).
# Contains an absolute path to the monorepo root so pip resolves it
# correctly regardless of clone location.
tests/fixtures/requirements.txt

# Test venv created by integration tests
tests/fixtures/.venv/

# Venv metadata cache written by VenvManager
tests/fixtures/.cpex/
32 changes: 32 additions & 0 deletions crates/cpex-hosts-python/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Location: ./crates/cpex-hosts-python/Cargo.toml
# Copyright 2026
# SPDX-License-Identifier: Apache-2.0
# Authors: Ted Habeck
#
# cpex-hosts-python — subprocess-isolated Python plugin adapter for the
# CPEX Rust PluginManager. Loads Python plugin classes via
# `kind: "isolated_venv"` and `config: class_name: module.ClassName` YAML config and drives
# them over the JSON-lines stdin/stdout protocol used by worker.py.

[package]
name = "cpex-hosts-python"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true

[dependencies]
cpex-core = { path = "../cpex-core" }

async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true, features = ["process", "io-util"] }
uuid = { workspace = true }
sha2 = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
tempfile = "3"
Loading