use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use ppe::praxis_policy_core::{
context::PluginContext,
error::PluginError,
extensions::Extensions,
hooks::payload::PluginPayload,
plugin::{Plugin, PluginConfig},
registry::AnyHookHandler,
};
use serde::Deserialize;
use crate::error::HostError;
use crate::legacy::cmf_shim;
use crate::venv::VenvManager;
use crate::worker::WorkerClient;
use crate::{conversion, credential_payloads, credentials};
const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
const MAX_RESPAWN_ATTEMPTS: u32 = 3;
const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
fn project_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn default_plugin_dirs() -> Vec<String> {
vec![
project_root()
.join(DEFAULT_PLUGIN_DIR)
.display()
.to_string(),
]
}
pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
const WORKER_KNOWN_CAPABILITIES: &[&str] = &[
"read_subject",
"read_roles",
"read_teams",
"read_claims",
"read_permissions",
"read_agent",
"read_headers",
"write_headers",
"read_labels",
"append_labels",
"read_delegation",
"append_delegation",
];
#[derive(Debug, Clone, Deserialize)]
pub struct VenvConfig {
pub class_name: String,
#[serde(default)]
pub requirements_file: Option<String>,
#[serde(default = "default_script_path")]
pub script_path: String,
#[serde(default = "default_max_content_size")]
pub max_content_size: usize,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
fn default_script_path() -> String {
DEFAULT_SCRIPT_PATH.to_string()
}
fn default_max_content_size() -> usize {
DEFAULT_MAX_CONTENT_SIZE
}
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
impl VenvConfig {
pub fn from_plugin_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
let raw = config
.config
.clone()
.unwrap_or_else(|| serde_json::json!({}));
serde_json::from_value(raw).map_err(|e| {
PluginError::Config {
message: format!(
"plugin '{}' (isolated_venv): invalid `config:` block — {}. \
`class_name` is required (the fully-qualified Python class, \
e.g. my_pkg.filters.PiiFilter)",
config.name, e
),
}
.boxed()
})
}
}
pub struct IsolatedPythonPlugin {
config: PluginConfig,
venv_config: VenvConfig,
plugin_dirs: Vec<String>,
venv: Option<VenvManager>,
worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
worker_override: Option<PathBuf>,
worker_cwd: Option<PathBuf>,
respawn: tokio::sync::Mutex<RespawnState>,
shut_down: std::sync::atomic::AtomicBool,
}
#[derive(Debug)]
struct RespawnState {
attempts: u32,
window_started: Option<std::time::Instant>,
}
impl RespawnState {
fn new() -> Self {
Self {
attempts: 0,
window_started: None,
}
}
fn try_reserve(&mut self) -> Option<u32> {
let now = std::time::Instant::now();
let expired = self
.window_started
.is_some_and(|s| now.duration_since(s).as_secs() >= RESPAWN_BUDGET_RESET_SECS);
if expired {
self.attempts = 0;
self.window_started = None;
}
if self.attempts >= MAX_RESPAWN_ATTEMPTS {
return None;
}
self.attempts += 1;
self.window_started.get_or_insert(now);
Some(self.attempts)
}
fn record_success(&mut self) {
self.attempts = 0;
self.window_started = None;
}
}
impl IsolatedPythonPlugin {
pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
let venv_config = VenvConfig::from_plugin_config(config)?;
let plugin_dirs = default_plugin_dirs();
let venv = VenvManager::new(
&plugin_dirs,
&venv_config.class_name,
venv_config.requirements_file.as_deref(),
config.version.as_deref(),
)
.ok();
Ok(Self {
config: config.clone(),
venv_config,
plugin_dirs,
venv,
worker: tokio::sync::RwLock::new(None),
worker_override: None,
worker_cwd: None,
respawn: tokio::sync::Mutex::new(RespawnState::new()),
shut_down: std::sync::atomic::AtomicBool::new(false),
})
}
fn is_shut_down(&self) -> bool {
self.shut_down.load(std::sync::atomic::Ordering::SeqCst)
}
fn shut_down_error() -> HostError {
HostError::WorkerStart {
message: "the plugin has been shut down, so its worker is not respawned".into(),
}
}
pub fn plugin_dirs(&self) -> &[String] {
&self.plugin_dirs
}
#[cfg(any(test, feature = "testing"))]
pub fn with_worker_cwd(mut self, cwd: PathBuf) -> Self {
self.worker_cwd = Some(cwd);
self
}
#[cfg(any(test, feature = "testing"))]
pub fn with_plugin_dirs(mut self, dirs: Vec<String>) -> Self {
self.venv = VenvManager::new(
&dirs,
&self.venv_config.class_name,
self.venv_config.requirements_file.as_deref(),
self.config.version.as_deref(),
)
.ok();
self.plugin_dirs = dirs;
self
}
#[cfg(any(test, feature = "testing"))]
pub fn with_worker_override(mut self, script: PathBuf) -> Self {
self.worker_override = Some(script);
self
}
pub fn venv_config(&self) -> &VenvConfig {
&self.venv_config
}
#[cfg(any(test, feature = "testing"))]
pub async fn worker_stderr(&self) -> Vec<String> {
match self.worker.read().await.as_ref() {
Some(worker) => worker.stderr_lines().await,
None => Vec::new(),
}
}
async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
let (python, script) = match self.worker_override.as_ref() {
Some(script) => (which_python3()?, script.clone()),
None => {
let venv = self.venv.as_ref().ok_or_else(|| HostError::Config {
message: format!(
"could not resolve a venv layout under {} — check that `class_name` \
('{}') begins with a package segment",
self.plugin_dirs.join(", "),
self.venv_config.class_name,
),
})?;
venv.ensure().await?;
let script = crate::worker::resolve_worker_script(
&venv.layout().venv_path,
&self.venv_config.script_path,
)?;
(venv.python_executable(), script)
},
};
WorkerClient::spawn(
&python,
&script,
self.worker_cwd.as_deref(),
self.venv_config.max_content_size,
self.venv_config.timeout_secs,
)
.await
}
async fn worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
if let Some(worker) = self.worker.read().await.clone() {
if worker.is_alive() {
return Ok(worker);
}
} else if self.is_shut_down() {
return Err(to_plugin_error(Self::shut_down_error()));
} else {
return Err(to_plugin_error(HostError::WorkerStart {
message: "plugin has no worker — initialize() did not run or it failed".into(),
}));
}
self.respawn_worker().await
}
async fn respawn_worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
let mut respawn = self.respawn.lock().await;
let dead = match self.worker.read().await.clone() {
Some(worker) if worker.is_alive() => return Ok(worker),
Some(_) if self.is_shut_down() => {
return Err(to_plugin_error(Self::shut_down_error()));
},
Some(worker) => worker,
None => return Err(to_plugin_error(Self::shut_down_error())),
};
let Some(attempt) = respawn.try_reserve() else {
tracing::error!(
plugin = %self.config.name,
max_attempts = MAX_RESPAWN_ATTEMPTS,
"worker died and the respawn budget is exhausted; giving up"
);
return Err(to_plugin_error(HostError::WorkerStart {
message: format!(
"worker died and could not be respawned after {MAX_RESPAWN_ATTEMPTS} attempts \
— the plugin's worker is failing to start (check its venv and dependencies)"
),
}));
};
let backoff = std::time::Duration::from_millis(
RESPAWN_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1)),
);
tracing::warn!(
plugin = %self.config.name,
attempt,
max_attempts = MAX_RESPAWN_ATTEMPTS,
backoff_ms = backoff.as_millis(),
"worker is dead; respawning"
);
tokio::time::sleep(backoff).await;
if self.is_shut_down() {
return Err(to_plugin_error(Self::shut_down_error()));
}
if let Err(e) = dead.shutdown().await {
tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
}
let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
if let Err(e) = self.verify_worker_understands(&worker).await {
stop_unused_worker(&self.config.name, &worker).await;
return Err(to_plugin_error(e));
}
{
let mut slot = self.worker.write().await;
if self.is_shut_down() {
drop(slot);
stop_unused_worker(&self.config.name, &worker).await;
return Err(to_plugin_error(Self::shut_down_error()));
}
*slot = Some(Arc::clone(&worker));
}
tracing::info!(
plugin = %self.config.name,
attempt,
"worker respawned; awaiting proof it can serve a request"
);
Ok(worker)
}
async fn note_worker_healthy(&self) {
let mut respawn = self.respawn.lock().await;
if respawn.attempts > 0 {
respawn.record_success();
}
}
fn worker_capabilities(&self) -> Vec<String> {
let (known, unknown): (Vec<String>, Vec<String>) = self
.config
.capabilities
.iter()
.cloned()
.partition(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
if !unknown.is_empty() {
tracing::warn!(
plugin = %self.config.name,
dropped = ?unknown,
"the installed cpex framework does not model these capabilities; \
omitting them from the worker's config so it can still validate"
);
}
known
}
async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
let required = self.required_worker_features();
let declares_cmf_hook = self.config.hooks.iter().any(|h| cmf_shim::is_cmf_hook(h));
if required.is_empty() {
if declares_cmf_hook {
match worker.capabilities().await {
Ok(reported) => self.log_cmf_dispatch(&reported),
Err(e) => tracing::warn!(
plugin = %self.config.name,
"worker capabilities probe failed ({e}); serving cmf hooks through the \
legacy payload shim"
),
}
}
return Ok(());
}
let reported = worker.capabilities().await.map_err(|e| {
if required.contains(&crate::worker::FEATURE_CMF_HOOKS) {
HostError::Config {
message: format!(
"plugin '{}' declares an LLM hook, but the worker's `cmf_hooks` support \
could not be verified ({e}); upgrade the cpex package in the plugin's venv",
self.config.name
),
}
} else {
e
}
})?;
if declares_cmf_hook {
self.log_cmf_dispatch(&reported);
}
let missing: Vec<&str> = required
.iter()
.copied()
.filter(|f| !reported.has(f))
.collect();
if missing.is_empty() {
tracing::debug!(
plugin = %self.config.name,
protocol_version = reported.protocol_version,
cpex_version = ?reported.cpex_version,
"worker handshake satisfied"
);
return Ok(());
}
let mut declared: Vec<&str> = self
.config
.capabilities
.iter()
.map(String::as_str)
.collect();
declared.sort_unstable();
Err(HostError::Credential {
message: format!(
"the installed cpex worker does not implement {} — which plugin '{}' needs given \
its declared capabilities ({}) and hooks ({}). The worker reported protocol \
version {} and features [{}] (cpex {}). Running anyway would drop those fields \
silently, so this plugin is failed closed at startup; upgrade the cpex package \
in the plugin's venv.",
missing.join(", "),
self.config.name,
declared.join(", "),
self.config.hooks.join(", "),
reported.protocol_version,
reported
.features
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", "),
reported
.cpex_version
.as_deref()
.unwrap_or("version unknown"),
),
})
}
fn log_cmf_dispatch(&self, reported: &crate::worker::WorkerCapabilities) {
if reported.has(crate::worker::FEATURE_CMF_HOOKS) {
tracing::debug!(plugin = %self.config.name, "worker serves cmf hooks natively");
} else {
tracing::info!(
plugin = %self.config.name,
"worker does not claim `{}`; serving cmf tool, prompt and resource hooks \
through the legacy payload shim",
crate::worker::FEATURE_CMF_HOOKS
);
}
}
fn required_worker_features(&self) -> Vec<&'static str> {
let mut required = Vec::new();
if self
.config
.hooks
.iter()
.any(|h| matches!(h.as_str(), "cmf.llm_input" | "cmf.llm_output"))
{
required.push(crate::worker::FEATURE_CMF_HOOKS);
}
let declares_credential_cap = self.config.capabilities.iter().any(|c| {
c == crate::credentials::CAP_READ_INBOUND || c == crate::credentials::CAP_READ_DELEGATED
});
if declares_credential_cap {
required.push(crate::worker::FEATURE_CREDENTIAL);
}
let declares_extension_cap = self
.config
.capabilities
.iter()
.any(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
if declares_extension_cap {
required.push(crate::worker::FEATURE_EXTENSIONS);
}
required
}
fn build_task(
&self,
hook_name: &str,
legacy_cmf: bool,
payload: serde_json::Value,
ctx: &PluginContext,
) -> Result<serde_json::Value, HostError> {
let worker_hooks: Vec<&str> = self
.config
.hooks
.iter()
.map(|hook| worker_hook_for(hook, legacy_cmf))
.collect();
let config_json = serde_json::json!({
"name": self.config.name,
"kind": self.config.kind,
"hooks": worker_hooks,
"capabilities": self.worker_capabilities(),
"config": self.config.config.clone().unwrap_or(serde_json::Value::Null),
});
let config_string =
serde_json::to_string(&config_json).map_err(|e| HostError::Protocol {
message: format!("could not serialize the plugin config for the worker: {e}"),
})?;
let request_id = ctx
.get_global(GLOBAL_REQUEST_ID_KEY)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Ok(serde_json::json!({
"task_type": crate::worker::TASK_LOAD_AND_RUN_HOOK,
"plugin_dirs": self.plugin_dirs,
"class_name": self.venv_config.class_name,
"config": config_string,
"hook_type": worker_hook_for(hook_name, legacy_cmf),
"plugin_name": self.config.name,
"payload": payload,
"context": {
"state": ctx.local_state,
"global_context": {
"request_id": request_id,
"state": ctx.global_state,
},
"metadata": {},
},
}))
}
}
#[async_trait]
impl Plugin for IsolatedPythonPlugin {
fn config(&self) -> &PluginConfig {
&self.config
}
async fn initialize(&self) -> Result<(), Box<PluginError>> {
self.shut_down
.store(false, std::sync::atomic::Ordering::SeqCst);
let worker = self
.launch_worker()
.await
.map_err(|e| e.into_plugin_error(&self.config.name))?;
self.verify_worker_understands(&worker)
.await
.map_err(|e| e.into_plugin_error(&self.config.name))?;
*self.worker.write().await = Some(Arc::new(worker));
Ok(())
}
async fn shutdown(&self) -> Result<(), Box<PluginError>> {
self.shut_down
.store(true, std::sync::atomic::Ordering::SeqCst);
let worker = self.worker.write().await.take();
let result = match worker {
Some(worker) => worker
.shutdown()
.await
.map_err(|e| e.into_plugin_error(&self.config.name)),
None => Ok(()),
};
if let Some(venv) = self.venv.as_ref()
&& let Err(e) = venv.release_owner().await
{
tracing::warn!(
plugin = %self.config.name,
"could not release the venv owner claim: {e}"
);
}
result
}
}
async fn stop_unused_worker(plugin: &str, worker: &WorkerClient) {
if let Err(e) = worker.shutdown().await {
tracing::debug!(plugin = %plugin, "stopping an unused worker: {e}");
}
}
fn worker_hook_for(hook_name: &str, legacy_cmf: bool) -> &str {
if legacy_cmf && let Some(legacy) = cmf_shim::legacy_hook_name(hook_name) {
return legacy;
}
conversion::worker_hook_name(hook_name)
}
fn needs_legacy_cmf(worker: &WorkerClient) -> bool {
!worker
.reported_capabilities()
.is_some_and(|c| c.has(crate::worker::FEATURE_CMF_HOOKS))
}
fn which_python3() -> Result<PathBuf, HostError> {
let candidates = [
"/usr/bin/python3",
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
];
if let Some(found) = candidates.iter().map(Path::new).find(|p| p.exists()) {
return Ok(found.to_path_buf());
}
let output = std::process::Command::new("sh")
.args(["-c", "command -v python3"])
.output()
.map_err(|e| HostError::WorkerStart {
message: format!("could not look up python3: {e}"),
})?;
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
return Err(HostError::WorkerStart {
message: "python3 was not found on PATH".into(),
});
}
Ok(PathBuf::from(path))
}
pub struct PythonHookAdapter {
plugin: Arc<IsolatedPythonPlugin>,
hook_name: &'static str,
}
impl PythonHookAdapter {
pub fn new(plugin: Arc<IsolatedPythonPlugin>, hook_name: &'static str) -> Self {
Self { plugin, hook_name }
}
}
#[async_trait]
impl AnyHookHandler for PythonHookAdapter {
async fn invoke(
&self,
payload: &dyn PluginPayload,
extensions: &Extensions,
ctx: &mut PluginContext,
) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
let plugin_name = &self.plugin.config.name;
let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name);
let worker = self.plugin.worker().await?;
if cmf_shim::is_cmf_hook(self.hook_name)
&& worker.reported_capabilities().is_none()
&& worker.claim_probe_retry()
{
match worker.capabilities().await {
Ok(reported) => self.plugin.log_cmf_dispatch(&reported),
Err(e) => tracing::warn!(
plugin = %plugin_name,
"worker capabilities probe failed again ({e}); serving cmf hooks through \
the legacy payload shim for this worker"
),
}
}
let legacy_cmf = needs_legacy_cmf(&worker);
let shim = if legacy_cmf && cmf_shim::is_cmf_hook(self.hook_name) {
Some(cmf_shim::to_legacy(self.hook_name, payload).map_err(to_plugin_error)?)
} else {
None
};
let payload_json = match shim.as_ref() {
Some(shim) => shim.payload().clone(),
None => conversion::serialize_payload(payload).map_err(to_plugin_error)?,
};
let mut task = self
.plugin
.build_task(self.hook_name, legacy_cmf, payload_json, ctx)
.map_err(to_plugin_error)?;
credentials::attach_credential_for_payload(
&mut task,
self.hook_name,
payload,
extensions,
&self.plugin.config.capabilities,
)
.map_err(to_plugin_error)?;
crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?;
let mut response = worker.send_task(task).await.map_err(to_plugin_error)?;
self.plugin.note_worker_healthy().await;
apply_context_deltas(&response, ctx);
let translated = shim.is_some() || credential_payloads::is_ppe_credential_payload(payload);
let returned_modified = if translated {
response
.as_object_mut()
.and_then(|r| r.remove("modified_payload"))
} else {
None
};
let mut fields = conversion::response_to_result(self.hook_name, response, extensions)
.map_err(to_plugin_error)?;
if let Some(modified) = returned_modified.filter(|m| !m.is_null()) {
apply_translated_modification(shim.as_ref(), payload, modified, &mut fields)
.map_err(to_plugin_error)?;
}
Ok(Box::new(fields))
}
fn hook_type_name(&self) -> &'static str {
ppe::praxis_policy_core::hooks::lookup_hook_metadata(self.hook_name)
.and_then(|meta| meta.family)
.unwrap_or(self.hook_name)
}
}
fn apply_translated_modification(
shim: Option<&cmf_shim::ShimmedPayload>,
payload: &dyn PluginPayload,
modified: serde_json::Value,
fields: &mut ppe::praxis_policy_core::executor::ErasedResultFields,
) -> Result<(), HostError> {
use ppe::praxis_policy_core::delegation::DelegationPayload;
use ppe::praxis_policy_core::identity::IdentityPayload;
if !fields.continue_processing {
fields.modified_payload = None;
return Ok(());
}
if let Some(shim) = shim {
fields.modified_payload = Some(Box::new(shim.apply_modified(modified)?));
return Ok(());
}
let any = payload.as_any();
if let Some(identity) = any.downcast_ref::<IdentityPayload>() {
match credential_payloads::identity_from_worker(identity, modified)? {
credential_payloads::IdentityOutcome::Resolved(resolved) => {
fields.modified_payload = Some(resolved);
},
credential_payloads::IdentityOutcome::Rejected(violation) => {
fields.modified_payload = None;
fields.continue_processing = false;
fields.violation = Some(violation);
},
}
} else if let Some(delegation) = any.downcast_ref::<DelegationPayload>() {
fields.modified_payload = Some(Box::new(credential_payloads::delegation_from_worker(
delegation, modified,
)?));
}
Ok(())
}
fn apply_context_deltas(response: &serde_json::Value, ctx: &mut PluginContext) {
let Some(context) = response.get("context") else {
return;
};
if let Some(state) = context.get("state").and_then(|v| v.as_object()) {
for (key, value) in state {
ctx.local_state.insert(key.clone(), value.clone());
}
}
if let Some(global) = context
.get("global_context")
.and_then(|g| g.get("state"))
.and_then(|v| v.as_object())
{
for (key, value) in global {
ctx.global_state.insert(key.clone(), value.clone());
}
}
}
#[cfg(test)]
mod tests {
use crate::testing::TempDir;
use super::*;
fn config_with(value: serde_json::Value) -> PluginConfig {
PluginConfig {
name: "py-plugin".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(value),
..Default::default()
}
}
#[test]
fn full_config_block_deserializes_every_field() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.filters.PiiFilter",
"requirements_file": "requirements.txt",
"plugin_dirs": ["./plugins", "./vendor"],
"script_path": "custom/worker.py",
"max_content_size": 2048,
"timeout_secs": 5,
})))
.expect("a fully populated block parses");
assert_eq!(cfg.class_name, "my_pkg.filters.PiiFilter");
assert_eq!(cfg.requirements_file.as_deref(), Some("requirements.txt"));
assert_eq!(cfg.script_path, "custom/worker.py");
assert_eq!(cfg.max_content_size, 2048);
assert_eq!(cfg.timeout_secs, 5);
}
#[test]
fn absent_optionals_fall_back_to_documented_defaults() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
})))
.expect("class_name alone is a valid block");
assert_eq!(
cfg.max_content_size, 10_000_000,
"matches client.py's max_content_size"
);
assert_eq!(
cfg.timeout_secs, 30,
"matches venv_comm.py's send_task timeout"
);
assert_eq!(cfg.script_path, "cpex/framework/isolated/worker.py");
assert!(
cfg.requirements_file.is_none(),
"no requirements file is valid"
);
}
#[test]
fn missing_class_name_errors() {
let err = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"plugin_dirs": ["./plugins"],
})))
.expect_err("class_name is required");
assert!(matches!(*err, PluginError::Config { .. }));
assert!(err.to_string().contains("class_name"));
}
#[test]
fn absent_config_block_errors_rather_than_panicking() {
let cfg = PluginConfig {
name: "no-config".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: None,
..Default::default()
};
let err = VenvConfig::from_plugin_config(&cfg)
.expect_err("an absent block cannot supply class_name");
assert!(matches!(*err, PluginError::Config { .. }));
}
fn expected_default_dir() -> String {
std::env::current_dir()
.unwrap()
.join("plugins")
.display()
.to_string()
}
#[test]
fn plugin_dirs_default_to_plugins_at_the_project_root() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
})))
.expect("class_name alone is a valid block");
assert_eq!(
plugin.plugin_dirs(),
[expected_default_dir()],
"the host supplies the plugin dir; the config no longer can"
);
assert!(
Path::new(&plugin.plugin_dirs()[0]).is_absolute(),
"the resolved dir must be absolute: {:?}",
plugin.plugin_dirs()
);
}
#[test]
fn a_plugin_dirs_key_in_the_config_block_is_ignored() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"plugin_dirs": ["/somewhere/else", "/and/here"],
})))
.expect("an ignored key must not fail the parse");
assert_eq!(
plugin.plugin_dirs(),
[expected_default_dir()],
"`plugin_dirs` in the config block must be ignored"
);
}
#[test]
fn ppe_hook_names_reach_the_worker_in_its_own_spelling() {
let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
config.hooks = vec![
"identity.resolve".into(),
"token.delegate".into(),
"cmf.prompt_pre_invoke".into(),
"cmf.tool_pre_invoke".into(),
];
let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses");
for (dispatched, worker) in [
("identity.resolve", "identity_resolve"),
("token.delegate", "token_delegate"),
("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"),
("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"),
] {
let task = plugin
.build_task(
dispatched,
false,
serde_json::json!({}),
&PluginContext::new(),
)
.expect("task builds");
assert_eq!(task["hook_type"], worker, "hook_type for {dispatched}");
}
let task = plugin
.build_task(
"identity.resolve",
false,
serde_json::json!({}),
&PluginContext::new(),
)
.expect("task builds");
assert_eq!(
worker_config(&task)["hooks"],
serde_json::json!([
"identity_resolve",
"token_delegate",
"cmf.prompt_pre_fetch",
"cmf.tool_pre_invoke"
])
);
}
#[test]
fn under_the_shim_cmf_hooks_reach_the_worker_under_legacy_names() {
let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
config.hooks = vec![
"cmf.tool_pre_invoke".into(),
"cmf.prompt_post_invoke".into(),
"cmf.resource_pre_fetch".into(),
"cmf.llm_input".into(),
"identity.resolve".into(),
];
let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses");
let task = plugin
.build_task(
"cmf.prompt_post_invoke",
true,
serde_json::json!({}),
&PluginContext::new(),
)
.expect("task builds");
assert_eq!(task["hook_type"], "prompt_post_fetch");
assert_eq!(
worker_config(&task)["hooks"],
serde_json::json!([
"tool_pre_invoke",
"prompt_post_fetch",
"resource_pre_fetch",
"cmf.llm_input",
"identity_resolve"
])
);
}
#[test]
fn the_resolved_plugin_dir_is_what_the_worker_receives() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"plugin_dirs": ["/ignored"],
})))
.expect("parses");
let task = plugin
.build_task(
"tool_pre_invoke",
false,
serde_json::json!({"name": "t"}),
&PluginContext::new(),
)
.expect("task builds");
assert_eq!(
task["plugin_dirs"],
serde_json::json!([expected_default_dir()]),
"the worker must be told the resolved dir, not the ignored one"
);
}
fn worker_config(task: &serde_json::Value) -> serde_json::Value {
serde_json::from_str(task["config"].as_str().expect("config is a string"))
.expect("the worker's config parses")
}
fn task_for_capabilities(capabilities: &[&str]) -> serde_json::Value {
let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
config.capabilities = capabilities.iter().map(|c| (*c).to_string()).collect();
IsolatedPythonPlugin::from_config(&config)
.expect("parses")
.build_task(
"tool_pre_invoke",
false,
serde_json::json!({"name": "t"}),
&PluginContext::new(),
)
.expect("task builds")
}
#[test]
fn declared_capabilities_reach_the_workers_config() {
let config = worker_config(&task_for_capabilities(&["read_labels", "append_labels"]));
let mut sent: Vec<&str> = config["capabilities"]
.as_array()
.expect("capabilities is an array")
.iter()
.map(|c| c.as_str().expect("a string"))
.collect();
sent.sort_unstable();
assert_eq!(
sent,
["append_labels", "read_labels"],
"the worker must be told what the plugin declared"
);
}
#[test]
fn a_capability_the_python_model_rejects_is_not_forwarded() {
let config = worker_config(&task_for_capabilities(&["read_labels", "read_client"]));
assert_eq!(
config["capabilities"],
serde_json::json!(["read_labels"]),
"a name the installed framework cannot validate must be dropped, \
not sent and not escalated into a task failure"
);
}
#[test]
fn a_plugin_declaring_nothing_sends_an_empty_capability_list() {
let config = worker_config(&task_for_capabilities(&[]));
assert_eq!(
config["capabilities"],
serde_json::json!([]),
"no capabilities is an empty list, not a missing field"
);
}
const ADAPTER_STUB: &str = r#"
import json, os, sys
here = os.path.dirname(os.path.abspath(__file__))
def mode_file(name, default=""):
try:
with open(os.path.join(here, name)) as f:
return f.read().strip()
except FileNotFoundError:
return default
def mode():
return mode_file("mode", "allow")
def append(name, text):
with open(os.path.join(here, name), "a") as f:
f.write(text + "\n")
def count(name):
try:
with open(os.path.join(here, name)) as f:
return len(f.read().splitlines())
except FileNotFoundError:
return 0
# One line per launched process, so a test can tell whether a worker started.
append("spawns", str(os.getpid()))
while True:
line = sys.stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
task = json.loads(line)
rid = task.get("request_id", "unknown")
if task.get("task_type") == "shutdown":
print(json.dumps({"status": "success", "request_id": rid}), flush=True)
break
# Answered before the recording below, so the spawn-time handshake does not
# overwrite the `last_task.json` the tests assert their hook task against.
# This stub stands in for a *current* worker, so it claims the features the
# real one implements; `handshake_mode` covers the old-worker case.
if task.get("task_type") == "capabilities":
hs = mode_file("handshake_mode")
append("probes", hs)
# An over-limit reply fails the probe the way a transient transport
# fault does, without the delay a timeout would cost. `flaky_cmf` fails
# only the first probe; `broken` fails every one.
if hs == "broken" or (hs == "flaky_cmf" and count("probes") == 1):
print(json.dumps({"request_id": rid, "pad": "x" * 8192}), flush=True)
continue
if hs == "unsupported":
# Exactly what a worker predating the handshake replies.
print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True)
continue
features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"]
if hs in ("cmf", "flaky_cmf"):
features.append("cmf_hooks")
print(json.dumps({
"status": "success", "protocol_version": 1,
"features": features, "cpex_version": "0.1.2", "request_id": rid,
}), flush=True)
continue
# Record the task so the test can assert on what the host actually sent.
with open(os.path.join(here, "last_task.json"), "w") as f:
json.dump(task, f)
# ...and which process served it, so a respawn test can prove the request
# was answered by a *new* worker rather than by the original recovering.
with open(os.path.join(here, "last_pid"), "w") as f:
f.write(str(os.getpid()))
m = mode()
# `die` exits without replying, which is how a test kills the worker so the
# *next* invocation has a dead one to respawn.
if m == "die":
sys.exit(9)
if m in ("deny", "deny_unmappable"):
response = {
"continue_processing": False,
"violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}},
}
if m == "deny_unmappable":
response["modified_payload"] = {"headers": {"authorization": "invalid"}}
elif m == "error":
response = {"status": "error", "message": "the plugin raised ValueError"}
elif m == "context":
response = {
"continue_processing": True,
"context": {"state": {"seen": 1}, "global_context": {"state": {"shared": "yes"}}},
}
elif m == "modify":
response = {
"continue_processing": True,
"modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}},
}
elif m == "identity_result":
# What the worker dumps for a Python IdentityResult.
response = {
"continue_processing": True,
"modified_payload": {
"subject": {"id": "alice", "type": "user", "roles": ["hr"],
"permissions": [], "teams": [], "claims": {}},
"delegation": None, "rejected": False, "reject_status": 401,
"reject_reason": "", "raw_claims": {"sub": "alice"},
},
}
elif m == "identity_rejected":
response = {
"continue_processing": True,
"modified_payload": {"subject": None, "rejected": True,
"reject_status": 401, "reject_reason": "token expired"},
}
elif m == "delegation_result":
response = {
"continue_processing": True,
"modified_payload": {
"delegated_token": "STUB-MINTED-TOKEN",
"delegation_update": {"chain": [], "depth": 0, "origin_subject_id": None,
"actor_subject_id": None, "delegated": False,
"age_seconds": 0.0},
"forwarded_headers": {}, "cache_key": None, "cache_ttl": None,
},
}
else:
response = {"continue_processing": True}
response["request_id"] = rid
print(json.dumps(response), flush=True)
"#;
async fn adapter_plugin(
hooks: &[&str],
capabilities: &[&str],
) -> (TempDir, Arc<IsolatedPythonPlugin>) {
let (dir, plugin) = uninitialized_adapter_plugin(hooks, capabilities);
plugin.initialize().await.expect("the stub worker launches");
(dir, plugin)
}
fn set_mode(dir: &TempDir, mode: &str) {
std::fs::write(dir.path().join("mode"), mode).unwrap();
}
fn set_handshake_mode(dir: &TempDir, mode: &str) {
std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
}
fn uninitialized_adapter_plugin(
hooks: &[&str],
capabilities: &[&str],
) -> (TempDir, Arc<IsolatedPythonPlugin>) {
uninitialized_adapter_plugin_with(
hooks,
capabilities,
serde_json::json!({ "class_name": "my_pkg.Plugin" }),
)
}
fn uninitialized_adapter_plugin_with(
hooks: &[&str],
capabilities: &[&str],
config_block: serde_json::Value,
) -> (TempDir, Arc<IsolatedPythonPlugin>) {
let dir = TempDir::new();
let script = dir.path().join("adapter_stub.py");
std::fs::write(&script, ADAPTER_STUB).unwrap();
let config = PluginConfig {
name: "py-plugin".into(),
kind: crate::factory::KIND.into(),
hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(),
config: Some(config_block),
..Default::default()
};
let plugin = Arc::new(
IsolatedPythonPlugin::from_config(&config)
.expect("config parses")
.with_worker_override(script),
);
(dir, plugin)
}
fn last_task(dir: &TempDir) -> serde_json::Value {
let raw = std::fs::read_to_string(dir.path().join("last_task.json"))
.expect("the stub recorded a task");
serde_json::from_str(&raw).expect("recorded task is JSON")
}
async fn invoke(
plugin: &Arc<IsolatedPythonPlugin>,
hook: &'static str,
payload: &dyn PluginPayload,
ctx: &mut PluginContext,
) -> Result<ppe::praxis_policy_core::executor::ErasedResultFields, Box<PluginError>> {
let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook);
let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?;
Ok(ppe::praxis_policy_core::executor::extract_erased(erased)
.expect("the adapter returns ErasedResultFields"))
}
#[tokio::test]
async fn an_invoke_round_trips_and_returns_allow() {
if crate::testing::skip_without_python3("an_invoke_round_trips_and_returns_allow") {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
args: Some(std::collections::HashMap::from([(
"q".into(),
serde_json::json!("rust"),
)])),
headers: None,
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the round trip succeeds");
assert!(fields.continue_processing);
assert!(fields.violation.is_none());
let task = last_task(&dir);
assert_eq!(task["task_type"], "load_and_run_hook");
assert_eq!(task["hook_type"], "tool_pre_invoke");
assert_eq!(task["plugin_name"], "py-plugin");
assert_eq!(task["class_name"], "my_pkg.Plugin");
assert_eq!(task["payload"]["name"], "search");
assert_eq!(task["payload"]["args"]["q"], "rust");
let config_str = task["config"]
.as_str()
.expect("config must be a JSON string");
let config: serde_json::Value = serde_json::from_str(config_str).unwrap();
assert_eq!(config["name"], "py-plugin");
plugin.shutdown().await.expect("shuts down");
}
#[tokio::test]
async fn a_deny_response_becomes_a_deny_result_with_its_violation() {
if crate::testing::skip_without_python3(
"a_deny_response_becomes_a_deny_result_with_its_violation",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "deny");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("a deny is a successful invocation that returns a deny");
assert!(!fields.continue_processing);
let violation = fields.violation.expect("a deny must carry its violation");
assert_eq!(violation.code, "PII_DETECTED");
assert_eq!(violation.reason, "email in args");
assert_eq!(violation.details["field"], "q");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police() {
if crate::testing::skip_without_python3(
"a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "error");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a worker error response must not read as success");
};
match *err {
PluginError::Execution {
ref plugin_name,
ref code,
ref message,
..
} => {
assert_eq!(plugin_name, "py-plugin");
assert_eq!(code.as_deref(), Some("worker_error"));
assert!(
message.contains("ValueError"),
"the plugin's message should survive: {message}"
);
},
ref other => panic!("expected an Execution error, got {other:?}"),
}
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn context_deltas_are_written_back_into_the_plugin_context() {
if crate::testing::skip_without_python3(
"context_deltas_are_written_back_into_the_plugin_context",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "context");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
ctx.set_local("preexisting", serde_json::json!("kept"));
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
assert_eq!(ctx.get_local("seen"), Some(&serde_json::json!(1)));
assert_eq!(ctx.get_global("shared"), Some(&serde_json::json!("yes")));
assert_eq!(
ctx.get_local("preexisting"),
Some(&serde_json::json!("kept")),
"deltas merge — a plugin touching one key must not blank the rest"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_modified_payload_comes_back_as_the_hooks_typed_payload() {
if crate::testing::skip_without_python3(
"a_modified_payload_comes_back_as_the_hooks_typed_payload",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "modify");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
let modified = fields.modified_payload.expect("the modification survives");
let typed = modified
.as_any()
.downcast_ref::<crate::legacy::ToolPreInvokePayload>()
.expect("must come back as the tool payload or the executor's downcast fails");
assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker() {
if crate::testing::skip_without_python3(
"a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker",
) {
return;
}
let (dir, plugin) = uninitialized_adapter_plugin(&["cmf.tool_pre_invoke"], &[]);
set_handshake_mode(&dir, "cmf");
plugin.initialize().await.expect("the stub worker launches");
set_mode(&dir, "allow");
let payload = ppe::praxis_policy_core::cmf::MessagePayload {
message: ppe::praxis_policy_core::cmf::Message::text(
ppe::praxis_policy_core::cmf::Role::User,
"hello",
),
};
let mut ctx = PluginContext::new();
invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
let task = last_task(&dir);
assert_eq!(task["hook_type"], "cmf.tool_pre_invoke");
assert_eq!(
task["payload"]["message"]["role"], "user",
"a cmf hook must send the CMF message payload, not the generic wrapper"
);
plugin.shutdown().await.unwrap();
}
fn cmf_tool_call(q: &str) -> ppe::praxis_policy_core::cmf::MessagePayload {
use ppe::praxis_policy_core::cmf::{ContentPart, Message, Role, ToolCall};
ppe::praxis_policy_core::cmf::MessagePayload {
message: Message::with_content(
Role::Assistant,
vec![
ContentPart::Text {
text: String::new(),
},
ContentPart::ToolCall {
content: ToolCall {
tool_call_id: "call-7".into(),
name: "search".into(),
arguments: std::collections::HashMap::from([(
"q".into(),
serde_json::json!(q),
)]),
namespace: None,
},
},
],
),
}
}
#[tokio::test]
async fn a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload() {
if crate::testing::skip_without_python3(
"a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
let fields = invoke(
&plugin,
"cmf.tool_pre_invoke",
&cmf_tool_call("rust"),
&mut ctx,
)
.await
.expect("the shimmed round trip succeeds");
assert!(fields.continue_processing);
assert!(fields.modified_payload.is_none());
let task = last_task(&dir);
assert_eq!(task["hook_type"], "tool_pre_invoke");
assert_eq!(
task["payload"],
serde_json::json!({"name": "search", "args": {"q": "rust"}})
);
assert_eq!(
worker_config(&task)["hooks"],
serde_json::json!(["tool_pre_invoke"])
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_legacy_modification_maps_back_onto_the_cmf_message() {
if crate::testing::skip_without_python3(
"a_legacy_modification_maps_back_onto_the_cmf_message",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
set_mode(&dir, "modify");
let mut ctx = PluginContext::new();
let fields = invoke(
&plugin,
"cmf.tool_pre_invoke",
&cmf_tool_call("alice@example.com"),
&mut ctx,
)
.await
.unwrap();
let modified = fields.modified_payload.expect("the modification survives");
let message = modified
.as_any()
.downcast_ref::<ppe::praxis_policy_core::cmf::MessagePayload>()
.expect("a cmf hook's modification must come back as a MessagePayload");
let json = serde_json::to_value(message).unwrap();
assert_eq!(json["message"]["content"][0]["text"], "");
assert_eq!(
json["message"]["content"][1]["content"]["arguments"]["q"],
"[REDACTED]"
);
assert_eq!(
json["message"]["content"][1]["content"]["tool_call_id"],
"call-7"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_legacy_deny_passes_through_the_shim_unchanged() {
if crate::testing::skip_without_python3("a_legacy_deny_passes_through_the_shim_unchanged") {
return;
}
let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
for mode in ["deny", "deny_unmappable"] {
set_mode(&dir, mode);
let mut ctx = PluginContext::new();
let fields = invoke(
&plugin,
"cmf.tool_pre_invoke",
&cmf_tool_call("x"),
&mut ctx,
)
.await
.expect("an unusable modification must not replace a denial with an error");
assert!(!fields.continue_processing);
assert!(fields.modified_payload.is_none());
let violation = fields.violation.expect("the deny keeps its violation");
assert_eq!(violation.code, "PII_DETECTED");
assert_eq!(violation.details["field"], "q");
}
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn an_unrepresentable_message_fails_closed_before_reaching_the_worker() {
if crate::testing::skip_without_python3(
"an_unrepresentable_message_fails_closed_before_reaching_the_worker",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let no_tool_call = ppe::praxis_policy_core::cmf::MessagePayload {
message: ppe::praxis_policy_core::cmf::Message::text(
ppe::praxis_policy_core::cmf::Role::User,
"hello",
),
};
for hook in ["cmf.tool_pre_invoke"] {
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, hook, &no_tool_call, &mut ctx).await else {
panic!("'{hook}' must fail closed rather than allow");
};
let message = err.to_string();
assert!(message.contains(hook), "names the hook: {message}");
assert!(message.contains("py-plugin"), "names the plugin: {message}");
match *err {
PluginError::Execution { ref code, .. } => {
assert_eq!(code.as_deref(), Some("legacy_shim_unsupported"));
},
ref other => panic!("expected an Execution error, got {other:?}"),
}
}
assert!(
!dir.path().join("last_task.json").exists(),
"nothing may reach the worker for a message the shim refused"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() {
let config = PluginConfig {
name: "uninitialized".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("dispatch without a worker cannot succeed");
};
assert!(matches!(*err, PluginError::Execution { .. }));
}
#[tokio::test]
async fn a_credential_capability_attaches_the_dto_on_an_identity_hook() {
if crate::testing::skip_without_python3(
"a_credential_capability_attaches_the_dto_on_an_identity_hook",
) {
return;
}
use ppe::praxis_policy_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) =
adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let mut ctx = PluginContext::new();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds");
let task = last_task(&dir);
assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN");
assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
assert_eq!(
task["hook_type"], "identity_resolve",
"the worker registers the identity hook under its own spelling"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_credential_capability_attaches_the_dto_on_a_delegation_hook() {
if crate::testing::skip_without_python3(
"a_credential_capability_attaches_the_dto_on_a_delegation_hook",
) {
return;
}
use ppe::praxis_policy_core::extensions::raw_credentials::{
DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken,
};
let (dir, plugin) = adapter_plugin(&["token.delegate"], &["read_delegated_tokens"]).await;
set_mode(&dir, "allow");
let audience = "https://billing.example.com";
let mut raw = RawCredentialsExtension::default();
raw.delegated_tokens.insert(
DelegationKey::new(DelegationMode::OnBehalfOfUser, audience, vec![]),
RawDelegatedToken::new(
"E2E-DELEGATED-TOKEN",
"Authorization",
audience,
vec![],
chrono::Utc::now(),
),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::TokenDelegatePayload {
target_name: "billing".into(),
target_audience: Some(audience.into()),
..Default::default()
};
let mut ctx = PluginContext::new();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "token.delegate");
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds");
let task = last_task(&dir);
assert_eq!(
task["credential"]["delegated"]["token"],
"E2E-DELEGATED-TOKEN"
);
assert_eq!(task["hook_type"], "token_delegate");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn no_capability_means_no_credential_reaches_the_worker() {
if crate::testing::skip_without_python3(
"no_capability_means_no_credential_reaches_the_worker",
) {
return;
}
use ppe::praxis_policy_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let mut ctx = PluginContext::new();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.unwrap();
let task = last_task(&dir);
assert!(task.get("credential").is_none(), "no credential field");
assert!(
!serde_json::to_string(&task)
.unwrap()
.contains("E2E-SECRET-TOKEN"),
"no token bytes anywhere in the task the worker received"
);
plugin.shutdown().await.unwrap();
}
fn last_pid(dir: &TempDir) -> u32 {
std::fs::read_to_string(dir.path().join("last_pid"))
.expect("the stub recorded its pid")
.trim()
.parse()
.expect("pid is numeric")
}
async fn await_worker_death(plugin: &Arc<IsolatedPythonPlugin>) {
for _ in 0..200 {
let alive = plugin
.worker
.read()
.await
.as_ref()
.is_some_and(|w| w.is_alive());
if !alive {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("the worker never registered as dead");
}
#[tokio::test]
async fn a_dead_worker_is_respawned_on_the_next_invocation() {
if crate::testing::skip_without_python3("a_dead_worker_is_respawned_on_the_next_invocation")
{
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the first invocation succeeds");
let original_pid = last_pid(&dir);
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("a dead worker must be replaced, not left broken");
assert!(fields.continue_processing);
let new_pid = last_pid(&dir);
assert_ne!(
original_pid, new_pid,
"the request must be served by a respawned process"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup() {
if crate::testing::skip_without_python3(
"a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup",
) {
return;
}
let (dir, plugin) =
uninitialized_adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]);
set_handshake_mode(&dir, "unsupported");
let err = plugin
.initialize()
.await
.expect_err("a worker that would drop the credential must not be installed");
let message = err.to_string();
assert!(
message.contains("does not implement credential"),
"the failure must name the missing feature: {message}"
);
assert!(
message.contains("upgrade the cpex package"),
"the failure must tell an operator what to do: {message}"
);
assert!(
plugin.worker.read().await.is_none(),
"a failed handshake must not leave a worker in the slot"
);
}
#[tokio::test]
async fn llm_hooks_require_a_worker_with_native_cmf_support() {
if crate::testing::skip_without_python3(
"llm_hooks_require_a_worker_with_native_cmf_support",
) {
return;
}
for hook in ["cmf.llm_input", "cmf.llm_output"] {
for handshake in ["no_features", "unsupported"] {
let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]);
set_handshake_mode(&dir, handshake);
let err = plugin
.initialize()
.await
.expect_err("the shim cannot serve an LLM hook");
assert!(err.to_string().contains("cmf_hooks"), "{err}");
assert!(plugin.worker.read().await.is_none());
}
let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]);
set_handshake_mode(&dir, "cmf");
plugin
.initialize()
.await
.expect("a native CMF worker can serve an LLM hook");
plugin.shutdown().await.unwrap();
}
}
#[tokio::test]
async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() {
if crate::testing::skip_without_python3(
"a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker",
) {
return;
}
let (dir, plugin) = uninitialized_adapter_plugin(&["tool_pre_invoke"], &[]);
set_handshake_mode(&dir, "unsupported");
plugin
.initialize()
.await
.expect("a plugin needing no gated field must still start on an old worker");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the hook still dispatches");
assert!(fields.continue_processing);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_answering_the_handshake_with_no_features_also_fails_closed() {
if crate::testing::skip_without_python3(
"a_worker_answering_the_handshake_with_no_features_also_fails_closed",
) {
return;
}
let (dir, plugin) =
uninitialized_adapter_plugin(&["tool_pre_invoke"], &["read_labels", "append_labels"]);
set_handshake_mode(&dir, "no_features");
let err = plugin
.initialize()
.await
.expect_err("an extensions-declaring plugin needs a worker that delivers the field");
let message = err.to_string();
assert!(
message.contains("does not implement extensions"),
"an extensions capability requires the extensions feature: {message}"
);
}
#[tokio::test]
async fn a_respawned_worker_receives_the_same_capability_handshake() {
if crate::testing::skip_without_python3(
"a_respawned_worker_receives_the_same_capability_handshake",
) {
return;
}
use ppe::praxis_policy_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) =
adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("the first dispatch succeeds");
let original_pid = last_pid(&dir);
assert_eq!(
last_task(&dir)["credential"]["inbound"]["token"],
"RESPAWN-SECRET"
);
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds against the respawned worker");
assert_ne!(original_pid, last_pid(&dir), "a new process served it");
let task = last_task(&dir);
assert_eq!(
task["credential"]["inbound"]["token"], "RESPAWN-SECRET",
"the respawned worker must get the same credential handshake"
);
assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_respawned_worker_gets_no_credential_it_did_not_declare() {
if crate::testing::skip_without_python3(
"a_respawned_worker_gets_no_credential_it_did_not_declare",
) {
return;
}
use ppe::praxis_policy_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.unwrap();
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds after respawn");
let task = last_task(&dir);
assert!(
task.get("credential").is_none(),
"respawn must not widen the capability gate"
);
assert!(
!serde_json::to_string(&task)
.unwrap()
.contains("MUST-NOT-LEAK"),
"no token bytes may reach a worker that declared nothing"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_that_will_not_stay_up_gives_up_with_a_typed_error() {
if crate::testing::skip_without_python3(
"a_worker_that_will_not_stay_up_gives_up_with_a_typed_error",
) {
return;
}
let dir = TempDir::new();
let script = dir.path().join("always_dies.py");
std::fs::write(
&script,
"import sys\nprint('fatal: dependency missing', file=sys.stderr, flush=True)\nsys.exit(1)\n",
)
.unwrap();
let config = PluginConfig {
name: "crash-loop".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(
IsolatedPythonPlugin::from_config(&config)
.expect("config parses")
.with_worker_override(script),
);
plugin.initialize().await.expect("the process launches");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let started = std::time::Instant::now();
let mut gave_up = None;
for _ in 0..(MAX_RESPAWN_ATTEMPTS + 4) {
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a worker that always dies cannot serve a request");
};
let message = err.to_string();
if message.contains("could not be respawned") {
gave_up = Some(message);
break;
}
}
let message = gave_up
.expect("a permanently failing worker must give up with an error, not retry forever");
assert!(
message.contains(&MAX_RESPAWN_ATTEMPTS.to_string()),
"the error should name the attempt bound: {message}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"giving up took too long: {:?}",
started.elapsed()
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn respawn_is_not_attempted_before_initialize() {
let config = PluginConfig {
name: "uninitialized".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
let Err(err) = plugin.worker().await else {
panic!("a plugin with no worker must not silently spawn one");
};
let message = err.to_string();
assert!(
message.contains("initialize()"),
"the error should name the lifecycle gap: {message}"
);
assert!(
plugin.worker.read().await.is_none(),
"no worker was started"
);
}
#[tokio::test]
async fn concurrent_invocations_share_one_respawn() {
if crate::testing::skip_without_python3("concurrent_invocations_share_one_respawn") {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut handles = Vec::new();
for _ in 0..4 {
let plugin = Arc::clone(&plugin);
handles.push(tokio::spawn(async move {
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.map(|f| f.continue_processing)
}));
}
for handle in handles {
assert!(handle.await.unwrap().expect("each invocation recovers"));
}
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("budget was not exhausted by the concurrent respawn");
plugin.shutdown().await.unwrap();
}
fn lines_in(dir: &TempDir, name: &str) -> usize {
std::fs::read_to_string(dir.path().join(name))
.map(|s| s.lines().count())
.unwrap_or(0)
}
#[tokio::test]
async fn a_failed_respawn_is_retried_on_the_next_invocation() {
if crate::testing::skip_without_python3(
"a_failed_respawn_is_retried_on_the_next_invocation",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &["read_labels"]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
let original_pid = last_pid(&dir);
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_handshake_mode(&dir, "no_features");
set_mode(&dir, "allow");
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a replacement that fails verification must not serve the request");
};
assert!(
err.to_string().contains("does not implement extensions"),
"{err}"
);
assert!(
plugin.worker.read().await.is_some(),
"the dead worker stays in the slot so a later call can respawn again"
);
std::fs::remove_file(dir.path().join("handshake_mode")).unwrap();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("a later invocation retries the respawn");
assert!(fields.continue_processing);
assert_ne!(original_pid, last_pid(&dir), "served by a new process");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_respawn_racing_shutdown_launches_no_worker() {
if crate::testing::skip_without_python3("a_respawn_racing_shutdown_launches_no_worker") {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
assert_eq!(lines_in(&dir, "spawns"), 1);
let racing = {
let plugin = Arc::clone(&plugin);
tokio::spawn(async move {
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.map(|_| ())
})
};
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
plugin.shutdown().await.unwrap();
let Err(err) = racing.await.unwrap() else {
panic!("an invocation racing shutdown must not be served by a new worker");
};
assert!(err.to_string().contains("shut down"), "{err}");
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
lines_in(&dir, "spawns"),
1,
"no worker launched after shutdown"
);
assert!(plugin.worker.read().await.is_none(), "the slot stays empty");
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a shut-down plugin cannot serve a request");
};
assert!(err.to_string().contains("shut down"), "{err}");
}
#[tokio::test]
async fn a_transient_probe_failure_is_retried_before_choosing_the_shim() {
if crate::testing::skip_without_python3(
"a_transient_probe_failure_is_retried_before_choosing_the_shim",
) {
return;
}
let (dir, plugin) = uninitialized_adapter_plugin_with(
&["cmf.tool_pre_invoke"],
&[],
serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }),
);
set_handshake_mode(&dir, "flaky_cmf");
plugin
.initialize()
.await
.expect("a failed probe does not stop a plugin that requires nothing");
set_mode(&dir, "allow");
let payload = ppe::praxis_policy_core::cmf::MessagePayload {
message: ppe::praxis_policy_core::cmf::Message::text(
ppe::praxis_policy_core::cmf::Role::User,
"hello",
),
};
let mut ctx = PluginContext::new();
invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
.await
.expect("served natively once the retried probe answers");
let task = last_task(&dir);
assert_eq!(
task["hook_type"], "cmf.tool_pre_invoke",
"the retried probe's report chooses native dispatch, not the shim"
);
assert_eq!(lines_in(&dir, "probes"), 2);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_probe_that_keeps_failing_is_retried_once_per_worker() {
if crate::testing::skip_without_python3(
"a_probe_that_keeps_failing_is_retried_once_per_worker",
) {
return;
}
let (dir, plugin) = uninitialized_adapter_plugin_with(
&["cmf.tool_pre_invoke"],
&[],
serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }),
);
set_handshake_mode(&dir, "broken");
plugin.initialize().await.unwrap();
set_mode(&dir, "allow");
for _ in 0..3 {
let mut ctx = PluginContext::new();
invoke(
&plugin,
"cmf.tool_pre_invoke",
&cmf_tool_call("rust"),
&mut ctx,
)
.await
.expect("the shim still serves the hook");
assert_eq!(last_task(&dir)["hook_type"], "tool_pre_invoke");
}
assert_eq!(
lines_in(&dir, "probes"),
2,
"one startup probe and one retry, not one per call"
);
plugin.shutdown().await.unwrap();
}
fn ppe_identity() -> ppe::praxis_policy_core::identity::IdentityPayload {
use ppe::praxis_policy_core::identity::{IdentityPayload, TokenSource};
IdentityPayload::new("PPE-IDENTITY-SECRET-TOKEN", TokenSource::Bearer).with_headers(
std::collections::HashMap::from([
(
"authorization".into(),
"Bearer PPE-IDENTITY-SECRET-TOKEN".into(),
),
("cookie".into(), "session=PPE-COOKIE-SECRET".into()),
("x-tenant".into(), "acme".into()),
]),
)
}
async fn invoke_on(
plugin: &Arc<IsolatedPythonPlugin>,
hook: &'static str,
payload: &dyn PluginPayload,
) -> ppe::praxis_policy_core::executor::ErasedResultFields {
let mut ctx = PluginContext::new();
invoke(plugin, hook, payload, &mut ctx)
.await
.expect("dispatch succeeds")
}
#[tokio::test]
async fn a_ppe_identity_payload_reaches_the_worker_without_its_credential() {
if crate::testing::skip_without_python3(
"a_ppe_identity_payload_reaches_the_worker_without_its_credential",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
set_mode(&dir, "allow");
invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
let task = last_task(&dir);
assert_eq!(task["hook_type"], "identity_resolve");
assert_eq!(task["payload"]["headers"], serde_json::json!({}));
assert!(task.get("credential").is_none());
let wire = serde_json::to_string(&task).unwrap();
assert!(!wire.contains("PPE-IDENTITY-SECRET-TOKEN"), "{wire}");
assert!(!wire.contains("PPE-COOKIE-SECRET"), "{wire}");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential() {
if crate::testing::skip_without_python3(
"a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential",
) {
return;
}
let (dir, plugin) =
adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
set_mode(&dir, "identity_result");
let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
let task = last_task(&dir);
assert_eq!(
task["credential"]["inbound"]["token"],
"PPE-IDENTITY-SECRET-TOKEN"
);
assert!(
!serde_json::to_string(&task["payload"])
.unwrap()
.contains("PPE-IDENTITY-SECRET-TOKEN"),
"the token rides only the gated channel"
);
let modified = fields
.modified_payload
.expect("the result is a modification");
let identity = modified
.as_any()
.downcast_ref::<ppe::praxis_policy_core::identity::IdentityPayload>()
.expect("identity.resolve must come back as PPE's IdentityPayload");
assert_eq!(
identity.subject.as_ref().and_then(|s| s.id.as_deref()),
Some("alice")
);
assert_eq!(identity.raw_token(), "PPE-IDENTITY-SECRET-TOKEN");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_rejected_identity_result_becomes_a_deny() {
if crate::testing::skip_without_python3("a_rejected_identity_result_becomes_a_deny") {
return;
}
let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
set_mode(&dir, "identity_rejected");
let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
assert!(!fields.continue_processing);
assert!(fields.modified_payload.is_none());
assert_eq!(fields.violation.unwrap().reason, "token expired");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_ppe_delegation_payload_round_trips_through_the_worker() {
if crate::testing::skip_without_python3(
"a_ppe_delegation_payload_round_trips_through_the_worker",
) {
return;
}
use ppe::praxis_policy_core::delegation::DelegationPayload;
let (dir, plugin) =
adapter_plugin(&["token.delegate"], &["read_inbound_credentials"]).await;
set_mode(&dir, "delegation_result");
let payload = DelegationPayload::new("PPE-CALLER-BEARER", "billing")
.with_target_audience("https://billing.example.com");
let fields = invoke_on(&plugin, "token.delegate", &payload).await;
let task = last_task(&dir);
assert_eq!(task["hook_type"], "token_delegate");
assert_eq!(task["payload"]["target_name"], "billing");
assert!(task["payload"].get("bearer_token").is_none());
assert_eq!(
task["credential"]["delegated"]["token"],
"PPE-CALLER-BEARER"
);
let modified = fields
.modified_payload
.expect("the result is a modification");
let delegation = modified
.as_any()
.downcast_ref::<DelegationPayload>()
.expect("token.delegate must come back as PPE's DelegationPayload");
let minted = delegation.delegated_token.as_ref().expect("a minted token");
assert_eq!(minted.token.as_str(), "STUB-MINTED-TOKEN");
assert_eq!(minted.audience, "https://billing.example.com");
plugin.shutdown().await.unwrap();
}
#[test]
fn the_respawn_budget_is_bounded_then_resets_with_time() {
let mut state = RespawnState::new();
for expected in 1..=MAX_RESPAWN_ATTEMPTS {
assert_eq!(state.try_reserve(), Some(expected));
}
assert_eq!(
state.try_reserve(),
None,
"the budget must be bounded, not unlimited"
);
state.record_success();
assert_eq!(state.try_reserve(), Some(1));
let mut state = RespawnState::new();
while state.try_reserve().is_some() {}
state.window_started = Some(
std::time::Instant::now()
- std::time::Duration::from_secs(RESPAWN_BUDGET_RESET_SECS + 1),
);
assert_eq!(
state.try_reserve(),
Some(1),
"an elapsed window must restore the budget"
);
}
#[test]
fn unknown_config_keys_are_tolerated() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"some_python_only_setting": true,
})))
.expect("extra keys belong to the Python plugin, not this host");
assert_eq!(cfg.class_name, "my_pkg.Minimal");
}
}