[FEATURE]: out-of-process host for existing Python CPEX plugins - #149
Open
tedhabeck wants to merge 5 commits into
Open
[FEATURE]: out-of-process host for existing Python CPEX plugins#149tedhabeck wants to merge 5 commits into
tedhabeck wants to merge 5 commits into
Conversation
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
tedhabeck
marked this pull request as ready for review
August 1, 2026 01:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes: #20
Adds
cpex-hosts-python, the crate that lets the RustPluginManagerrun thePython plugins that already exist — PII filters, identity resolvers, token
delegators — without rewriting them. It builds a per-plugin virtualenv, launches
the framework's
worker.pyin it as a long-lived subprocess, and speaks the samenewline-delimited-JSON stdio protocol the Python CLI already uses.
The point is migration continuity: a gateway can move to the Rust manager while
its existing Python plugins keep working unchanged.
Why
The Rust
PluginManagercannot run a Python plugin on its own. This crate is theRust counterpart to the Python CLI's
client.py+venv_comm.py. Rather thanport those as a one-to-one translation, it splits the three concerns — venv
build/cache, worker subprocess/protocol, and per-hook serialization — so each
tests in isolation and shared-package venv handling falls naturally to the venv
manager.
Venv construction and worker launch happen in
initialize()(rollback onfailure), teardown in
shutdown(). Neither belongs on the invoke path — a coldpip install is measured in minutes.
What's here
Venv lifecycle (
venv.rs) — build, cache, and resolve per-plugin venvs,including shared-package layouts. Cache metadata is keyed per host class.
Worker protocol (
worker.rs) — subprocess supervision and the NDJSON stdioframing, with the
max_content_sizeframe bound enforced on both sides.Hook dispatch (
plugin.rs,conversion.rs,legacy/) — one adapter perdeclared hook, carrying the native Pydantic payload shapes (
ToolPreInvokePayloadand friends) rather than a generic wrapper, so existing plugins validate as-is.
Credentials (
credentials.rs) — the framework strips raw tokens at everyprocess boundary (token fields are
#[serde(skip)]), but identity and delegationplugins genuinely need them. This adds a capability-gated wire DTO: a plugin
declaring
read_inbound_credentialsorread_delegated_tokensgets a dedicatedcredentialobject built by reading the in-memory token directly. Productioncredential types keep their serde guard and are never serialized. Fail-closed
rules and the residual exposure this does not close are documented in the
module.
Extensions delivery (
extensions.rs) — the executor's capability-filteredExtensionsis serialized onto the task, so a 3-arg(payload, context, extensions)hook sees out-of-process what it would see in-process. Returns comeback through the executor's existing copy-on-write merge, which enforces the
mutability tiers; this host adds no tier logic of its own. Sensitive headers
(
Authorization,Cookie,X-API-Key) are stripped in both directions,case-insensitively, and
raw_credentialsnever rides this channel.Tests
172 passing (152 unit + 20 integration), 2
#[ignore]d by default because theyneed an installed plugin with a built venv.
isolated_venv_e2e.rscredential_e2e.rsextensions_merge_e2e.rsconfig_e2e.rsinvoke_by_name→ executor → worker, against a plugin installed the way an operator installs oneextensions_merge_e2e.rsis worth a note on test design. The accept-side tieroutcomes can't be unit tested:
http,security, anddelegationwrites aregated by a
WriteTokenwhose constructor ispub(crate)tocpex-core, so thiscrate cannot mint one even in a test — which is exactly the property that makes
the gate trustworthy. Those paths are therefore driven through a real pipeline,
with tokens minted by the executor from declared capabilities. A
fake_workerstand-in covers the same merges without a Python subprocess by calling the
production parser on a canned response, so the code under test is identical and
only the subprocess is replaced.
Cross-surface verification, and one finding
The host and the Python worker land on different branches and ship separately, so
docs/specs/extensions-wire-contract.mdpins the contract they share — neitherside's source can serve as the other's reference.
This branch ran the two against each other for the first time, via populated
Extensionson thetool_pre_invokehook inconfig_e2e.rs. The channel works:extensions cross into a real worker subprocess, are reconstructed there, and the
hook runs. It also overturned two claims the spec had recorded about the known
http-slot divergence, in the reassuring direction.The divergence itself is unchanged and known — Rust
HttpExtensionhasrequest_headers/response_headers, Python has a singleheaders. The spec saida Rust-shaped slot fails validation in the Python model and is dropped with a
warning. Both claims are wrong. Neither model sets
extra="forbid"/deny_unknown_fields, and both default their header maps, so unknown keys aresilently ignored and missing ones default to empty:
{"request_headers": {"X-Request-Id": "r1"}}HttpExtension(headers={}){"headers": {"X-Fine": "yes"}}request_headers: {}, response_headers: {}Verified empirically against both models, not inferred. The slot arrives
present-but-empty — the "hollow slot" this same contract explicitly rejects for
raw_credentials. A plugin readsextensions.http.headers, gets{}, and cannotdistinguish "no headers on this request" from "the headers didn't cross."
Two follow-on corrections went into the spec:
reconstruct_extensionscallsmodel_validateon the whole object, so agenuinely-failing slot would zero out all extensions, not degrade per-slot as
the doc implied.
PipelineResult::modified_extensionsis the pipeline's final view and isalways
Some(...)on an allowed pipeline — not a "something changed" flag.Its own doc comment in
executor.rssays otherwise; noted in the spec, commentleft alone as out of scope here.
This is a correctness bug, not a security one. The sensitive-header strip runs
pre-serialization against the fields the sending model actually declares, so
Authorizationnever reaches the wire regardless of which shape the receiverexpects.
config_e2e.rsasserts exactly that: no credential header value appearsanywhere in the serialized task JSON.
Known gaps
Tracked as Remaining work in the spec:
httpslot needs a mapping on one side. The other eleven slots crosscorrectly.
that let the divergence stay misdescribed: existing tests assert either on the
wire JSON (Rust-shaped, so it passes) or on the hook running (it does). Write
it as a failing test, then fix (1).
mismatch into a silent one. Tightening trades the deliberate slot-level
version-skew tolerance, so it's a real design call rather than an obvious win.
important caveat on the verification above.
On (4), specifically
The worker side is unreleased (#113). Reproducing the run needs the plugin installed
and its venv's
cpexreplaced by hand:The version numbers collide. The branch build self-reports
0.1.2, and PyPIpublishes a
cpex0.1.2 that is a different artifact with no extensions support(no
EXTENSIONS_FIELD, noreconstruct_extensions). Nothing inpip listor thedist-info version distinguishes them, so a venv can look correctly provisioned and
silently have no extensions channel. Check
direct_url.json, not the version.The consequence for reviewers:
config_e2e.rscurrently guards only on the venvexisting, so a registry-provisioned venv yields a green test that proves
nothing — the worker ignores the unknown
extensionsfield, the hook still runs,the marker still lands. The guard should grep the installed
worker.pyforEXTENSIONS_FIELD, astesting::worker_delivers_extensionsalready does for theCPEX_PYTHON_SOURCEpath. Until then, treat a greenconfig_e2e.rsasconditional on having checked the venv's provenance.
Before merging
The working tree carries untracked artifacts that should be sorted out — none are
gitignored:
docs/specs/extensions-wire-contract.mdand thedocs/plans/+docs/brainstorms/files — these are worth committing; the spec isreferenced throughout this description.
Checks
make lintpassesmake testpasses