1use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use ppe::praxis_policy_core::{
22 context::PluginContext,
23 error::PluginError,
24 extensions::Extensions,
25 hooks::payload::PluginPayload,
26 plugin::{Plugin, PluginConfig},
27 registry::AnyHookHandler,
28};
29use serde::Deserialize;
30
31use crate::error::HostError;
32use crate::legacy::cmf_shim;
33use crate::venv::VenvManager;
34use crate::worker::WorkerClient;
35use crate::{conversion, credential_payloads, credentials};
36
37const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
43
44const DEFAULT_TIMEOUT_SECS: u64 = 30;
50
51const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
56
57const MAX_RESPAWN_ATTEMPTS: u32 = 3;
66
67const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
74
75const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
83
84pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
100
101fn project_root() -> PathBuf {
109 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
113}
114
115fn default_plugin_dirs() -> Vec<String> {
121 vec![
122 project_root()
123 .join(DEFAULT_PLUGIN_DIR)
124 .display()
125 .to_string(),
126 ]
127}
128
129pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
136
137const WORKER_KNOWN_CAPABILITIES: &[&str] = &[
151 "read_subject",
152 "read_roles",
153 "read_teams",
154 "read_claims",
155 "read_permissions",
156 "read_agent",
157 "read_headers",
158 "write_headers",
159 "read_labels",
160 "append_labels",
161 "read_delegation",
162 "append_delegation",
163];
164
165#[derive(Debug, Clone, Deserialize)]
180pub struct VenvConfig {
181 pub class_name: String,
185
186 #[serde(default)]
191 pub requirements_file: Option<String>,
192
193 #[serde(default = "default_script_path")]
196 pub script_path: String,
197
198 #[serde(default = "default_max_content_size")]
200 pub max_content_size: usize,
201
202 #[serde(default = "default_timeout_secs")]
205 pub timeout_secs: u64,
206}
207
208fn default_script_path() -> String {
209 DEFAULT_SCRIPT_PATH.to_string()
210}
211
212fn default_max_content_size() -> usize {
213 DEFAULT_MAX_CONTENT_SIZE
214}
215
216fn default_timeout_secs() -> u64 {
217 DEFAULT_TIMEOUT_SECS
218}
219
220impl VenvConfig {
221 pub fn from_plugin_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
226 let raw = config
227 .config
228 .clone()
229 .unwrap_or_else(|| serde_json::json!({}));
230
231 serde_json::from_value(raw).map_err(|e| {
232 PluginError::Config {
233 message: format!(
234 "plugin '{}' (isolated_venv): invalid `config:` block — {}. \
235 `class_name` is required (the fully-qualified Python class, \
236 e.g. my_pkg.filters.PiiFilter)",
237 config.name, e
238 ),
239 }
240 .boxed()
241 })
242 }
243}
244
245pub struct IsolatedPythonPlugin {
247 config: PluginConfig,
249
250 venv_config: VenvConfig,
252
253 plugin_dirs: Vec<String>,
261
262 venv: Option<VenvManager>,
266
267 worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
269
270 worker_override: Option<PathBuf>,
273
274 worker_cwd: Option<PathBuf>,
282
283 respawn: tokio::sync::Mutex<RespawnState>,
286
287 shut_down: std::sync::atomic::AtomicBool,
291}
292
293#[derive(Debug)]
300struct RespawnState {
301 attempts: u32,
303 window_started: Option<std::time::Instant>,
306}
307
308impl RespawnState {
309 fn new() -> Self {
310 Self {
311 attempts: 0,
312 window_started: None,
313 }
314 }
315
316 fn try_reserve(&mut self) -> Option<u32> {
321 let now = std::time::Instant::now();
322 let expired = self
323 .window_started
324 .is_some_and(|s| now.duration_since(s).as_secs() >= RESPAWN_BUDGET_RESET_SECS);
325
326 if expired {
327 self.attempts = 0;
328 self.window_started = None;
329 }
330
331 if self.attempts >= MAX_RESPAWN_ATTEMPTS {
332 return None;
333 }
334
335 self.attempts += 1;
336 self.window_started.get_or_insert(now);
337 Some(self.attempts)
338 }
339
340 fn record_success(&mut self) {
342 self.attempts = 0;
343 self.window_started = None;
344 }
345}
346
347impl IsolatedPythonPlugin {
348 pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
350 let venv_config = VenvConfig::from_plugin_config(config)?;
351
352 let plugin_dirs = default_plugin_dirs();
356
357 let venv = VenvManager::new(
362 &plugin_dirs,
363 &venv_config.class_name,
364 venv_config.requirements_file.as_deref(),
365 config.version.as_deref(),
366 )
367 .ok();
368
369 Ok(Self {
370 config: config.clone(),
371 venv_config,
372 plugin_dirs,
373 venv,
374 worker: tokio::sync::RwLock::new(None),
375 worker_override: None,
376 worker_cwd: None,
377 respawn: tokio::sync::Mutex::new(RespawnState::new()),
378 shut_down: std::sync::atomic::AtomicBool::new(false),
379 })
380 }
381
382 fn is_shut_down(&self) -> bool {
383 self.shut_down.load(std::sync::atomic::Ordering::SeqCst)
384 }
385
386 fn shut_down_error() -> HostError {
387 HostError::WorkerStart {
388 message: "the plugin has been shut down, so its worker is not respawned".into(),
389 }
390 }
391
392 pub fn plugin_dirs(&self) -> &[String] {
394 &self.plugin_dirs
395 }
396
397 #[cfg(any(test, feature = "testing"))]
404 pub fn with_worker_cwd(mut self, cwd: PathBuf) -> Self {
405 self.worker_cwd = Some(cwd);
406 self
407 }
408
409 #[cfg(any(test, feature = "testing"))]
420 pub fn with_plugin_dirs(mut self, dirs: Vec<String>) -> Self {
421 self.venv = VenvManager::new(
422 &dirs,
423 &self.venv_config.class_name,
424 self.venv_config.requirements_file.as_deref(),
425 self.config.version.as_deref(),
426 )
427 .ok();
428 self.plugin_dirs = dirs;
429 self
430 }
431
432 #[cfg(any(test, feature = "testing"))]
439 pub fn with_worker_override(mut self, script: PathBuf) -> Self {
440 self.worker_override = Some(script);
441 self
442 }
443
444 pub fn venv_config(&self) -> &VenvConfig {
446 &self.venv_config
447 }
448
449 #[cfg(any(test, feature = "testing"))]
453 pub async fn worker_stderr(&self) -> Vec<String> {
454 match self.worker.read().await.as_ref() {
455 Some(worker) => worker.stderr_lines().await,
456 None => Vec::new(),
457 }
458 }
459
460 async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
472 let (python, script) = match self.worker_override.as_ref() {
475 Some(script) => (which_python3()?, script.clone()),
476 None => {
477 let venv = self.venv.as_ref().ok_or_else(|| HostError::Config {
478 message: format!(
479 "could not resolve a venv layout under {} — check that `class_name` \
480 ('{}') begins with a package segment",
481 self.plugin_dirs.join(", "),
482 self.venv_config.class_name,
483 ),
484 })?;
485
486 venv.ensure().await?;
487 let script = crate::worker::resolve_worker_script(
488 &venv.layout().venv_path,
489 &self.venv_config.script_path,
490 )?;
491
492 (venv.python_executable(), script)
493 },
494 };
495
496 WorkerClient::spawn(
501 &python,
502 &script,
503 self.worker_cwd.as_deref(),
504 self.venv_config.max_content_size,
505 self.venv_config.timeout_secs,
506 )
507 .await
508 }
509
510 async fn worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
522 let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
523
524 if let Some(worker) = self.worker.read().await.clone() {
527 if worker.is_alive() {
528 return Ok(worker);
529 }
530 } else if self.is_shut_down() {
531 return Err(to_plugin_error(Self::shut_down_error()));
532 } else {
533 return Err(to_plugin_error(HostError::WorkerStart {
538 message: "plugin has no worker — initialize() did not run or it failed".into(),
539 }));
540 }
541
542 self.respawn_worker().await
543 }
544
545 async fn respawn_worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
552 let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
553 let mut respawn = self.respawn.lock().await;
554
555 let dead = match self.worker.read().await.clone() {
560 Some(worker) if worker.is_alive() => return Ok(worker),
561 Some(_) if self.is_shut_down() => {
562 return Err(to_plugin_error(Self::shut_down_error()));
563 },
564 Some(worker) => worker,
565 None => return Err(to_plugin_error(Self::shut_down_error())),
566 };
567
568 let Some(attempt) = respawn.try_reserve() else {
569 tracing::error!(
570 plugin = %self.config.name,
571 max_attempts = MAX_RESPAWN_ATTEMPTS,
572 "worker died and the respawn budget is exhausted; giving up"
573 );
574 return Err(to_plugin_error(HostError::WorkerStart {
575 message: format!(
576 "worker died and could not be respawned after {MAX_RESPAWN_ATTEMPTS} attempts \
577 — the plugin's worker is failing to start (check its venv and dependencies)"
578 ),
579 }));
580 };
581
582 let backoff = std::time::Duration::from_millis(
585 RESPAWN_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1)),
586 );
587 tracing::warn!(
588 plugin = %self.config.name,
589 attempt,
590 max_attempts = MAX_RESPAWN_ATTEMPTS,
591 backoff_ms = backoff.as_millis(),
592 "worker is dead; respawning"
593 );
594 tokio::time::sleep(backoff).await;
595
596 if self.is_shut_down() {
598 return Err(to_plugin_error(Self::shut_down_error()));
599 }
600
601 if let Err(e) = dead.shutdown().await {
608 tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
609 }
610
611 let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
613
614 if let Err(e) = self.verify_worker_understands(&worker).await {
619 stop_unused_worker(&self.config.name, &worker).await;
620 return Err(to_plugin_error(e));
621 }
622
623 {
627 let mut slot = self.worker.write().await;
628 if self.is_shut_down() {
629 drop(slot);
630 stop_unused_worker(&self.config.name, &worker).await;
631 return Err(to_plugin_error(Self::shut_down_error()));
632 }
633 *slot = Some(Arc::clone(&worker));
634 }
635
636 tracing::info!(
649 plugin = %self.config.name,
650 attempt,
651 "worker respawned; awaiting proof it can serve a request"
652 );
653
654 Ok(worker)
655 }
656
657 async fn note_worker_healthy(&self) {
664 let mut respawn = self.respawn.lock().await;
665 if respawn.attempts > 0 {
666 respawn.record_success();
667 }
668 }
669
670 fn worker_capabilities(&self) -> Vec<String> {
689 let (known, unknown): (Vec<String>, Vec<String>) = self
690 .config
691 .capabilities
692 .iter()
693 .cloned()
694 .partition(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
695
696 if !unknown.is_empty() {
697 tracing::warn!(
701 plugin = %self.config.name,
702 dropped = ?unknown,
703 "the installed cpex framework does not model these capabilities; \
704 omitting them from the worker's config so it can still validate"
705 );
706 }
707
708 known
709 }
710
711 async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
729 let required = self.required_worker_features();
730
731 let declares_cmf_hook = self.config.hooks.iter().any(|h| cmf_shim::is_cmf_hook(h));
736 if required.is_empty() {
737 if declares_cmf_hook {
738 match worker.capabilities().await {
739 Ok(reported) => self.log_cmf_dispatch(&reported),
740 Err(e) => tracing::warn!(
741 plugin = %self.config.name,
742 "worker capabilities probe failed ({e}); serving cmf hooks through the \
743 legacy payload shim"
744 ),
745 }
746 }
747 return Ok(());
748 }
749
750 let reported = worker.capabilities().await.map_err(|e| {
751 if required.contains(&crate::worker::FEATURE_CMF_HOOKS) {
752 HostError::Config {
753 message: format!(
754 "plugin '{}' declares an LLM hook, but the worker's `cmf_hooks` support \
755 could not be verified ({e}); upgrade the cpex package in the plugin's venv",
756 self.config.name
757 ),
758 }
759 } else {
760 e
761 }
762 })?;
763 if declares_cmf_hook {
764 self.log_cmf_dispatch(&reported);
765 }
766 let missing: Vec<&str> = required
767 .iter()
768 .copied()
769 .filter(|f| !reported.has(f))
770 .collect();
771
772 if missing.is_empty() {
773 tracing::debug!(
774 plugin = %self.config.name,
775 protocol_version = reported.protocol_version,
776 cpex_version = ?reported.cpex_version,
777 "worker handshake satisfied"
778 );
779 return Ok(());
780 }
781
782 let mut declared: Vec<&str> = self
786 .config
787 .capabilities
788 .iter()
789 .map(String::as_str)
790 .collect();
791 declared.sort_unstable();
792
793 Err(HostError::Credential {
794 message: format!(
795 "the installed cpex worker does not implement {} — which plugin '{}' needs given \
796 its declared capabilities ({}) and hooks ({}). The worker reported protocol \
797 version {} and features [{}] (cpex {}). Running anyway would drop those fields \
798 silently, so this plugin is failed closed at startup; upgrade the cpex package \
799 in the plugin's venv.",
800 missing.join(", "),
801 self.config.name,
802 declared.join(", "),
803 self.config.hooks.join(", "),
804 reported.protocol_version,
805 reported
806 .features
807 .iter()
808 .map(String::as_str)
809 .collect::<Vec<_>>()
810 .join(", "),
811 reported
812 .cpex_version
813 .as_deref()
814 .unwrap_or("version unknown"),
815 ),
816 })
817 }
818
819 fn log_cmf_dispatch(&self, reported: &crate::worker::WorkerCapabilities) {
821 if reported.has(crate::worker::FEATURE_CMF_HOOKS) {
822 tracing::debug!(plugin = %self.config.name, "worker serves cmf hooks natively");
823 } else {
824 tracing::info!(
825 plugin = %self.config.name,
826 "worker does not claim `{}`; serving cmf tool, prompt and resource hooks \
827 through the legacy payload shim",
828 crate::worker::FEATURE_CMF_HOOKS
829 );
830 }
831 }
832
833 fn required_worker_features(&self) -> Vec<&'static str> {
839 let mut required = Vec::new();
840
841 if self
844 .config
845 .hooks
846 .iter()
847 .any(|h| matches!(h.as_str(), "cmf.llm_input" | "cmf.llm_output"))
848 {
849 required.push(crate::worker::FEATURE_CMF_HOOKS);
850 }
851
852 let declares_credential_cap = self.config.capabilities.iter().any(|c| {
856 c == crate::credentials::CAP_READ_INBOUND || c == crate::credentials::CAP_READ_DELEGATED
857 });
858 if declares_credential_cap {
859 required.push(crate::worker::FEATURE_CREDENTIAL);
860 }
861
862 let declares_extension_cap = self
867 .config
868 .capabilities
869 .iter()
870 .any(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
871 if declares_extension_cap {
872 required.push(crate::worker::FEATURE_EXTENSIONS);
873 }
874
875 required
876 }
877
878 fn build_task(
889 &self,
890 hook_name: &str,
891 legacy_cmf: bool,
892 payload: serde_json::Value,
893 ctx: &PluginContext,
894 ) -> Result<serde_json::Value, HostError> {
895 let worker_hooks: Vec<&str> = self
911 .config
912 .hooks
913 .iter()
914 .map(|hook| worker_hook_for(hook, legacy_cmf))
915 .collect();
916 let config_json = serde_json::json!({
917 "name": self.config.name,
918 "kind": self.config.kind,
919 "hooks": worker_hooks,
920 "capabilities": self.worker_capabilities(),
921 "config": self.config.config.clone().unwrap_or(serde_json::Value::Null),
922 });
923 let config_string =
924 serde_json::to_string(&config_json).map_err(|e| HostError::Protocol {
925 message: format!("could not serialize the plugin config for the worker: {e}"),
926 })?;
927
928 let request_id = ctx
933 .get_global(GLOBAL_REQUEST_ID_KEY)
934 .and_then(serde_json::Value::as_str)
935 .map(str::to_string)
936 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
937
938 Ok(serde_json::json!({
939 "task_type": crate::worker::TASK_LOAD_AND_RUN_HOOK,
940 "plugin_dirs": self.plugin_dirs,
941 "class_name": self.venv_config.class_name,
942 "config": config_string,
943 "hook_type": worker_hook_for(hook_name, legacy_cmf),
944 "plugin_name": self.config.name,
945 "payload": payload,
946 "context": {
947 "state": ctx.local_state,
951 "global_context": {
952 "request_id": request_id,
960 "state": ctx.global_state,
961 },
962 "metadata": {},
963 },
964 }))
965 }
966}
967
968#[async_trait]
969impl Plugin for IsolatedPythonPlugin {
970 fn config(&self) -> &PluginConfig {
971 &self.config
972 }
973
974 async fn initialize(&self) -> Result<(), Box<PluginError>> {
979 self.shut_down
980 .store(false, std::sync::atomic::Ordering::SeqCst);
981 let worker = self
982 .launch_worker()
983 .await
984 .map_err(|e| e.into_plugin_error(&self.config.name))?;
985
986 self.verify_worker_understands(&worker)
992 .await
993 .map_err(|e| e.into_plugin_error(&self.config.name))?;
994
995 *self.worker.write().await = Some(Arc::new(worker));
996 Ok(())
997 }
998
999 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1001 self.shut_down
1004 .store(true, std::sync::atomic::Ordering::SeqCst);
1005 let worker = self.worker.write().await.take();
1006 let result = match worker {
1007 Some(worker) => worker
1008 .shutdown()
1009 .await
1010 .map_err(|e| e.into_plugin_error(&self.config.name)),
1011 None => Ok(()),
1012 };
1013
1014 if let Some(venv) = self.venv.as_ref()
1019 && let Err(e) = venv.release_owner().await
1020 {
1021 tracing::warn!(
1022 plugin = %self.config.name,
1023 "could not release the venv owner claim: {e}"
1024 );
1025 }
1026
1027 result
1028 }
1029}
1030
1031async fn stop_unused_worker(plugin: &str, worker: &WorkerClient) {
1033 if let Err(e) = worker.shutdown().await {
1034 tracing::debug!(plugin = %plugin, "stopping an unused worker: {e}");
1035 }
1036}
1037
1038fn worker_hook_for(hook_name: &str, legacy_cmf: bool) -> &str {
1041 if legacy_cmf && let Some(legacy) = cmf_shim::legacy_hook_name(hook_name) {
1042 return legacy;
1043 }
1044 conversion::worker_hook_name(hook_name)
1045}
1046
1047fn needs_legacy_cmf(worker: &WorkerClient) -> bool {
1051 !worker
1052 .reported_capabilities()
1053 .is_some_and(|c| c.has(crate::worker::FEATURE_CMF_HOOKS))
1054}
1055
1056fn which_python3() -> Result<PathBuf, HostError> {
1062 let candidates = [
1063 "/usr/bin/python3",
1064 "/usr/local/bin/python3",
1065 "/opt/homebrew/bin/python3",
1066 ];
1067 if let Some(found) = candidates.iter().map(Path::new).find(|p| p.exists()) {
1068 return Ok(found.to_path_buf());
1069 }
1070
1071 let output = std::process::Command::new("sh")
1073 .args(["-c", "command -v python3"])
1074 .output()
1075 .map_err(|e| HostError::WorkerStart {
1076 message: format!("could not look up python3: {e}"),
1077 })?;
1078
1079 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1080 if path.is_empty() {
1081 return Err(HostError::WorkerStart {
1082 message: "python3 was not found on PATH".into(),
1083 });
1084 }
1085 Ok(PathBuf::from(path))
1086}
1087
1088pub struct PythonHookAdapter {
1093 plugin: Arc<IsolatedPythonPlugin>,
1094 hook_name: &'static str,
1095}
1096
1097impl PythonHookAdapter {
1098 pub fn new(plugin: Arc<IsolatedPythonPlugin>, hook_name: &'static str) -> Self {
1099 Self { plugin, hook_name }
1100 }
1101}
1102
1103#[async_trait]
1104impl AnyHookHandler for PythonHookAdapter {
1105 async fn invoke(
1112 &self,
1113 payload: &dyn PluginPayload,
1114 extensions: &Extensions,
1115 ctx: &mut PluginContext,
1116 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
1117 let plugin_name = &self.plugin.config.name;
1118 let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name);
1119
1120 let worker = self.plugin.worker().await?;
1124
1125 if cmf_shim::is_cmf_hook(self.hook_name)
1129 && worker.reported_capabilities().is_none()
1130 && worker.claim_probe_retry()
1131 {
1132 match worker.capabilities().await {
1133 Ok(reported) => self.plugin.log_cmf_dispatch(&reported),
1134 Err(e) => tracing::warn!(
1135 plugin = %plugin_name,
1136 "worker capabilities probe failed again ({e}); serving cmf hooks through \
1137 the legacy payload shim for this worker"
1138 ),
1139 }
1140 }
1141 let legacy_cmf = needs_legacy_cmf(&worker);
1142
1143 let shim = if legacy_cmf && cmf_shim::is_cmf_hook(self.hook_name) {
1147 Some(cmf_shim::to_legacy(self.hook_name, payload).map_err(to_plugin_error)?)
1148 } else {
1149 None
1150 };
1151
1152 let payload_json = match shim.as_ref() {
1153 Some(shim) => shim.payload().clone(),
1154 None => conversion::serialize_payload(payload).map_err(to_plugin_error)?,
1155 };
1156 let mut task = self
1157 .plugin
1158 .build_task(self.hook_name, legacy_cmf, payload_json, ctx)
1159 .map_err(to_plugin_error)?;
1160
1161 credentials::attach_credential_for_payload(
1164 &mut task,
1165 self.hook_name,
1166 payload,
1167 extensions,
1168 &self.plugin.config.capabilities,
1169 )
1170 .map_err(to_plugin_error)?;
1171
1172 crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?;
1176
1177 let mut response = worker.send_task(task).await.map_err(to_plugin_error)?;
1178
1179 self.plugin.note_worker_healthy().await;
1182
1183 apply_context_deltas(&response, ctx);
1186
1187 let translated = shim.is_some() || credential_payloads::is_ppe_credential_payload(payload);
1197 let returned_modified = if translated {
1198 response
1199 .as_object_mut()
1200 .and_then(|r| r.remove("modified_payload"))
1201 } else {
1202 None
1203 };
1204
1205 let mut fields = conversion::response_to_result(self.hook_name, response, extensions)
1210 .map_err(to_plugin_error)?;
1211
1212 if let Some(modified) = returned_modified.filter(|m| !m.is_null()) {
1213 apply_translated_modification(shim.as_ref(), payload, modified, &mut fields)
1214 .map_err(to_plugin_error)?;
1215 }
1216 Ok(Box::new(fields))
1217 }
1218
1219 fn hook_type_name(&self) -> &'static str {
1225 ppe::praxis_policy_core::hooks::lookup_hook_metadata(self.hook_name)
1226 .and_then(|meta| meta.family)
1227 .unwrap_or(self.hook_name)
1228 }
1229}
1230
1231fn apply_translated_modification(
1235 shim: Option<&cmf_shim::ShimmedPayload>,
1236 payload: &dyn PluginPayload,
1237 modified: serde_json::Value,
1238 fields: &mut ppe::praxis_policy_core::executor::ErasedResultFields,
1239) -> Result<(), HostError> {
1240 use ppe::praxis_policy_core::delegation::DelegationPayload;
1241 use ppe::praxis_policy_core::identity::IdentityPayload;
1242
1243 if !fields.continue_processing {
1246 fields.modified_payload = None;
1247 return Ok(());
1248 }
1249 if let Some(shim) = shim {
1250 fields.modified_payload = Some(Box::new(shim.apply_modified(modified)?));
1251 return Ok(());
1252 }
1253 let any = payload.as_any();
1254 if let Some(identity) = any.downcast_ref::<IdentityPayload>() {
1255 match credential_payloads::identity_from_worker(identity, modified)? {
1256 credential_payloads::IdentityOutcome::Resolved(resolved) => {
1257 fields.modified_payload = Some(resolved);
1258 },
1259 credential_payloads::IdentityOutcome::Rejected(violation) => {
1260 fields.modified_payload = None;
1262 fields.continue_processing = false;
1263 fields.violation = Some(violation);
1264 },
1265 }
1266 } else if let Some(delegation) = any.downcast_ref::<DelegationPayload>() {
1267 fields.modified_payload = Some(Box::new(credential_payloads::delegation_from_worker(
1268 delegation, modified,
1269 )?));
1270 }
1271 Ok(())
1272}
1273
1274fn apply_context_deltas(response: &serde_json::Value, ctx: &mut PluginContext) {
1280 let Some(context) = response.get("context") else {
1281 return;
1282 };
1283
1284 if let Some(state) = context.get("state").and_then(|v| v.as_object()) {
1285 for (key, value) in state {
1286 ctx.local_state.insert(key.clone(), value.clone());
1287 }
1288 }
1289
1290 if let Some(global) = context
1291 .get("global_context")
1292 .and_then(|g| g.get("state"))
1293 .and_then(|v| v.as_object())
1294 {
1295 for (key, value) in global {
1296 ctx.global_state.insert(key.clone(), value.clone());
1297 }
1298 }
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303 use crate::testing::TempDir;
1304
1305 use super::*;
1306
1307 fn config_with(value: serde_json::Value) -> PluginConfig {
1308 PluginConfig {
1309 name: "py-plugin".into(),
1310 kind: crate::factory::KIND.into(),
1311 hooks: vec!["tool_pre_invoke".into()],
1312 config: Some(value),
1313 ..Default::default()
1314 }
1315 }
1316
1317 #[test]
1318 fn full_config_block_deserializes_every_field() {
1319 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1320 "class_name": "my_pkg.filters.PiiFilter",
1321 "requirements_file": "requirements.txt",
1322 "plugin_dirs": ["./plugins", "./vendor"],
1323 "script_path": "custom/worker.py",
1324 "max_content_size": 2048,
1325 "timeout_secs": 5,
1326 })))
1327 .expect("a fully populated block parses");
1328
1329 assert_eq!(cfg.class_name, "my_pkg.filters.PiiFilter");
1330 assert_eq!(cfg.requirements_file.as_deref(), Some("requirements.txt"));
1331 assert_eq!(cfg.script_path, "custom/worker.py");
1332 assert_eq!(cfg.max_content_size, 2048);
1333 assert_eq!(cfg.timeout_secs, 5);
1334 }
1338
1339 #[test]
1340 fn absent_optionals_fall_back_to_documented_defaults() {
1341 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1342 "class_name": "my_pkg.Minimal",
1343 })))
1344 .expect("class_name alone is a valid block");
1345
1346 assert_eq!(
1349 cfg.max_content_size, 10_000_000,
1350 "matches client.py's max_content_size"
1351 );
1352 assert_eq!(
1353 cfg.timeout_secs, 30,
1354 "matches venv_comm.py's send_task timeout"
1355 );
1356 assert_eq!(cfg.script_path, "cpex/framework/isolated/worker.py");
1357
1358 assert!(
1359 cfg.requirements_file.is_none(),
1360 "no requirements file is valid"
1361 );
1362 }
1363
1364 #[test]
1365 fn missing_class_name_errors() {
1366 let err = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1367 "plugin_dirs": ["./plugins"],
1368 })))
1369 .expect_err("class_name is required");
1370
1371 assert!(matches!(*err, PluginError::Config { .. }));
1372 assert!(err.to_string().contains("class_name"));
1373 }
1374
1375 #[test]
1376 fn absent_config_block_errors_rather_than_panicking() {
1377 let cfg = PluginConfig {
1378 name: "no-config".into(),
1379 kind: crate::factory::KIND.into(),
1380 hooks: vec!["tool_pre_invoke".into()],
1381 config: None,
1382 ..Default::default()
1383 };
1384
1385 let err = VenvConfig::from_plugin_config(&cfg)
1386 .expect_err("an absent block cannot supply class_name");
1387 assert!(matches!(*err, PluginError::Config { .. }));
1388 }
1389
1390 fn expected_default_dir() -> String {
1393 std::env::current_dir()
1394 .unwrap()
1395 .join("plugins")
1396 .display()
1397 .to_string()
1398 }
1399
1400 #[test]
1401 fn plugin_dirs_default_to_plugins_at_the_project_root() {
1402 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1404 "class_name": "my_pkg.Minimal",
1405 })))
1406 .expect("class_name alone is a valid block");
1407
1408 assert_eq!(
1409 plugin.plugin_dirs(),
1410 [expected_default_dir()],
1411 "the host supplies the plugin dir; the config no longer can"
1412 );
1413 assert!(
1416 Path::new(&plugin.plugin_dirs()[0]).is_absolute(),
1417 "the resolved dir must be absolute: {:?}",
1418 plugin.plugin_dirs()
1419 );
1420 }
1421
1422 #[test]
1423 fn a_plugin_dirs_key_in_the_config_block_is_ignored() {
1424 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1428 "class_name": "my_pkg.Minimal",
1429 "plugin_dirs": ["/somewhere/else", "/and/here"],
1430 })))
1431 .expect("an ignored key must not fail the parse");
1432
1433 assert_eq!(
1434 plugin.plugin_dirs(),
1435 [expected_default_dir()],
1436 "`plugin_dirs` in the config block must be ignored"
1437 );
1438 }
1439
1440 #[test]
1441 fn ppe_hook_names_reach_the_worker_in_its_own_spelling() {
1442 let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
1446 config.hooks = vec![
1447 "identity.resolve".into(),
1448 "token.delegate".into(),
1449 "cmf.prompt_pre_invoke".into(),
1450 "cmf.tool_pre_invoke".into(),
1451 ];
1452 let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses");
1453
1454 for (dispatched, worker) in [
1455 ("identity.resolve", "identity_resolve"),
1456 ("token.delegate", "token_delegate"),
1457 ("cmf.prompt_pre_invoke", "cmf.prompt_pre_fetch"),
1458 ("cmf.tool_pre_invoke", "cmf.tool_pre_invoke"),
1459 ] {
1460 let task = plugin
1461 .build_task(
1462 dispatched,
1463 false,
1464 serde_json::json!({}),
1465 &PluginContext::new(),
1466 )
1467 .expect("task builds");
1468 assert_eq!(task["hook_type"], worker, "hook_type for {dispatched}");
1469 }
1470
1471 let task = plugin
1472 .build_task(
1473 "identity.resolve",
1474 false,
1475 serde_json::json!({}),
1476 &PluginContext::new(),
1477 )
1478 .expect("task builds");
1479 assert_eq!(
1480 worker_config(&task)["hooks"],
1481 serde_json::json!([
1482 "identity_resolve",
1483 "token_delegate",
1484 "cmf.prompt_pre_fetch",
1485 "cmf.tool_pre_invoke"
1486 ])
1487 );
1488 }
1489
1490 #[test]
1491 fn under_the_shim_cmf_hooks_reach_the_worker_under_legacy_names() {
1492 let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
1493 config.hooks = vec![
1494 "cmf.tool_pre_invoke".into(),
1495 "cmf.prompt_post_invoke".into(),
1496 "cmf.resource_pre_fetch".into(),
1497 "cmf.llm_input".into(),
1498 "identity.resolve".into(),
1499 ];
1500 let plugin = IsolatedPythonPlugin::from_config(&config).expect("parses");
1501
1502 let task = plugin
1503 .build_task(
1504 "cmf.prompt_post_invoke",
1505 true,
1506 serde_json::json!({}),
1507 &PluginContext::new(),
1508 )
1509 .expect("task builds");
1510 assert_eq!(task["hook_type"], "prompt_post_fetch");
1511 assert_eq!(
1512 worker_config(&task)["hooks"],
1513 serde_json::json!([
1514 "tool_pre_invoke",
1515 "prompt_post_fetch",
1516 "resource_pre_fetch",
1517 "cmf.llm_input",
1519 "identity_resolve"
1520 ])
1521 );
1522 }
1523
1524 #[test]
1525 fn the_resolved_plugin_dir_is_what_the_worker_receives() {
1526 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1530 "class_name": "my_pkg.Minimal",
1531 "plugin_dirs": ["/ignored"],
1532 })))
1533 .expect("parses");
1534
1535 let task = plugin
1536 .build_task(
1537 "tool_pre_invoke",
1538 false,
1539 serde_json::json!({"name": "t"}),
1540 &PluginContext::new(),
1541 )
1542 .expect("task builds");
1543
1544 assert_eq!(
1545 task["plugin_dirs"],
1546 serde_json::json!([expected_default_dir()]),
1547 "the worker must be told the resolved dir, not the ignored one"
1548 );
1549 }
1550
1551 fn worker_config(task: &serde_json::Value) -> serde_json::Value {
1553 serde_json::from_str(task["config"].as_str().expect("config is a string"))
1554 .expect("the worker's config parses")
1555 }
1556
1557 fn task_for_capabilities(capabilities: &[&str]) -> serde_json::Value {
1559 let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
1560 config.capabilities = capabilities.iter().map(|c| (*c).to_string()).collect();
1561
1562 IsolatedPythonPlugin::from_config(&config)
1563 .expect("parses")
1564 .build_task(
1565 "tool_pre_invoke",
1566 false,
1567 serde_json::json!({"name": "t"}),
1568 &PluginContext::new(),
1569 )
1570 .expect("task builds")
1571 }
1572
1573 #[test]
1574 fn declared_capabilities_reach_the_workers_config() {
1575 let config = worker_config(&task_for_capabilities(&["read_labels", "append_labels"]));
1581
1582 let mut sent: Vec<&str> = config["capabilities"]
1583 .as_array()
1584 .expect("capabilities is an array")
1585 .iter()
1586 .map(|c| c.as_str().expect("a string"))
1587 .collect();
1588 sent.sort_unstable();
1589
1590 assert_eq!(
1591 sent,
1592 ["append_labels", "read_labels"],
1593 "the worker must be told what the plugin declared"
1594 );
1595 }
1596
1597 #[test]
1598 fn a_capability_the_python_model_rejects_is_not_forwarded() {
1599 let config = worker_config(&task_for_capabilities(&["read_labels", "read_client"]));
1605
1606 assert_eq!(
1607 config["capabilities"],
1608 serde_json::json!(["read_labels"]),
1609 "a name the installed framework cannot validate must be dropped, \
1610 not sent and not escalated into a task failure"
1611 );
1612 }
1613
1614 #[test]
1615 fn a_plugin_declaring_nothing_sends_an_empty_capability_list() {
1616 let config = worker_config(&task_for_capabilities(&[]));
1621
1622 assert_eq!(
1623 config["capabilities"],
1624 serde_json::json!([]),
1625 "no capabilities is an empty list, not a missing field"
1626 );
1627 }
1628
1629 const ADAPTER_STUB: &str = r#"
1637import json, os, sys
1638
1639here = os.path.dirname(os.path.abspath(__file__))
1640
1641def mode_file(name, default=""):
1642 try:
1643 with open(os.path.join(here, name)) as f:
1644 return f.read().strip()
1645 except FileNotFoundError:
1646 return default
1647
1648def mode():
1649 return mode_file("mode", "allow")
1650
1651def append(name, text):
1652 with open(os.path.join(here, name), "a") as f:
1653 f.write(text + "\n")
1654
1655def count(name):
1656 try:
1657 with open(os.path.join(here, name)) as f:
1658 return len(f.read().splitlines())
1659 except FileNotFoundError:
1660 return 0
1661
1662# One line per launched process, so a test can tell whether a worker started.
1663append("spawns", str(os.getpid()))
1664
1665while True:
1666 line = sys.stdin.readline()
1667 if not line:
1668 break
1669 line = line.strip()
1670 if not line:
1671 continue
1672 task = json.loads(line)
1673 rid = task.get("request_id", "unknown")
1674
1675 if task.get("task_type") == "shutdown":
1676 print(json.dumps({"status": "success", "request_id": rid}), flush=True)
1677 break
1678
1679 # Answered before the recording below, so the spawn-time handshake does not
1680 # overwrite the `last_task.json` the tests assert their hook task against.
1681 # This stub stands in for a *current* worker, so it claims the features the
1682 # real one implements; `handshake_mode` covers the old-worker case.
1683 if task.get("task_type") == "capabilities":
1684 hs = mode_file("handshake_mode")
1685 append("probes", hs)
1686 # An over-limit reply fails the probe the way a transient transport
1687 # fault does, without the delay a timeout would cost. `flaky_cmf` fails
1688 # only the first probe; `broken` fails every one.
1689 if hs == "broken" or (hs == "flaky_cmf" and count("probes") == 1):
1690 print(json.dumps({"request_id": rid, "pad": "x" * 8192}), flush=True)
1691 continue
1692 if hs == "unsupported":
1693 # Exactly what a worker predating the handshake replies.
1694 print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True)
1695 continue
1696 features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"]
1697 if hs in ("cmf", "flaky_cmf"):
1698 features.append("cmf_hooks")
1699 print(json.dumps({
1700 "status": "success", "protocol_version": 1,
1701 "features": features, "cpex_version": "0.1.2", "request_id": rid,
1702 }), flush=True)
1703 continue
1704
1705 # Record the task so the test can assert on what the host actually sent.
1706 with open(os.path.join(here, "last_task.json"), "w") as f:
1707 json.dump(task, f)
1708
1709 # ...and which process served it, so a respawn test can prove the request
1710 # was answered by a *new* worker rather than by the original recovering.
1711 with open(os.path.join(here, "last_pid"), "w") as f:
1712 f.write(str(os.getpid()))
1713
1714 m = mode()
1715
1716 # `die` exits without replying, which is how a test kills the worker so the
1717 # *next* invocation has a dead one to respawn.
1718 if m == "die":
1719 sys.exit(9)
1720
1721 if m in ("deny", "deny_unmappable"):
1722 response = {
1723 "continue_processing": False,
1724 "violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}},
1725 }
1726 if m == "deny_unmappable":
1727 response["modified_payload"] = {"headers": {"authorization": "invalid"}}
1728 elif m == "error":
1729 response = {"status": "error", "message": "the plugin raised ValueError"}
1730 elif m == "context":
1731 response = {
1732 "continue_processing": True,
1733 "context": {"state": {"seen": 1}, "global_context": {"state": {"shared": "yes"}}},
1734 }
1735 elif m == "modify":
1736 response = {
1737 "continue_processing": True,
1738 "modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}},
1739 }
1740 elif m == "identity_result":
1741 # What the worker dumps for a Python IdentityResult.
1742 response = {
1743 "continue_processing": True,
1744 "modified_payload": {
1745 "subject": {"id": "alice", "type": "user", "roles": ["hr"],
1746 "permissions": [], "teams": [], "claims": {}},
1747 "delegation": None, "rejected": False, "reject_status": 401,
1748 "reject_reason": "", "raw_claims": {"sub": "alice"},
1749 },
1750 }
1751 elif m == "identity_rejected":
1752 response = {
1753 "continue_processing": True,
1754 "modified_payload": {"subject": None, "rejected": True,
1755 "reject_status": 401, "reject_reason": "token expired"},
1756 }
1757 elif m == "delegation_result":
1758 response = {
1759 "continue_processing": True,
1760 "modified_payload": {
1761 "delegated_token": "STUB-MINTED-TOKEN",
1762 "delegation_update": {"chain": [], "depth": 0, "origin_subject_id": None,
1763 "actor_subject_id": None, "delegated": False,
1764 "age_seconds": 0.0},
1765 "forwarded_headers": {}, "cache_key": None, "cache_ttl": None,
1766 },
1767 }
1768 else:
1769 response = {"continue_processing": True}
1770
1771 response["request_id"] = rid
1772 print(json.dumps(response), flush=True)
1773"#;
1774
1775 async fn adapter_plugin(
1777 hooks: &[&str],
1778 capabilities: &[&str],
1779 ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1780 let (dir, plugin) = uninitialized_adapter_plugin(hooks, capabilities);
1781 plugin.initialize().await.expect("the stub worker launches");
1782 (dir, plugin)
1783 }
1784
1785 fn set_mode(dir: &TempDir, mode: &str) {
1786 std::fs::write(dir.path().join("mode"), mode).unwrap();
1787 }
1788
1789 fn set_handshake_mode(dir: &TempDir, mode: &str) {
1794 std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
1795 }
1796
1797 fn uninitialized_adapter_plugin(
1803 hooks: &[&str],
1804 capabilities: &[&str],
1805 ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1806 uninitialized_adapter_plugin_with(
1807 hooks,
1808 capabilities,
1809 serde_json::json!({ "class_name": "my_pkg.Plugin" }),
1810 )
1811 }
1812
1813 fn uninitialized_adapter_plugin_with(
1815 hooks: &[&str],
1816 capabilities: &[&str],
1817 config_block: serde_json::Value,
1818 ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1819 let dir = TempDir::new();
1820 let script = dir.path().join("adapter_stub.py");
1821 std::fs::write(&script, ADAPTER_STUB).unwrap();
1822
1823 let config = PluginConfig {
1824 name: "py-plugin".into(),
1825 kind: crate::factory::KIND.into(),
1826 hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
1827 capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(),
1828 config: Some(config_block),
1829 ..Default::default()
1830 };
1831
1832 let plugin = Arc::new(
1833 IsolatedPythonPlugin::from_config(&config)
1834 .expect("config parses")
1835 .with_worker_override(script),
1836 );
1837 (dir, plugin)
1838 }
1839
1840 fn last_task(dir: &TempDir) -> serde_json::Value {
1841 let raw = std::fs::read_to_string(dir.path().join("last_task.json"))
1842 .expect("the stub recorded a task");
1843 serde_json::from_str(&raw).expect("recorded task is JSON")
1844 }
1845
1846 async fn invoke(
1848 plugin: &Arc<IsolatedPythonPlugin>,
1849 hook: &'static str,
1850 payload: &dyn PluginPayload,
1851 ctx: &mut PluginContext,
1852 ) -> Result<ppe::praxis_policy_core::executor::ErasedResultFields, Box<PluginError>> {
1853 let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook);
1854 let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?;
1855 Ok(ppe::praxis_policy_core::executor::extract_erased(erased)
1856 .expect("the adapter returns ErasedResultFields"))
1857 }
1858
1859 #[tokio::test]
1860 async fn an_invoke_round_trips_and_returns_allow() {
1861 if crate::testing::skip_without_python3("an_invoke_round_trips_and_returns_allow") {
1862 return;
1863 }
1864 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1865 set_mode(&dir, "allow");
1866
1867 let payload = crate::legacy::ToolPreInvokePayload {
1868 name: "search".into(),
1869 args: Some(std::collections::HashMap::from([(
1870 "q".into(),
1871 serde_json::json!("rust"),
1872 )])),
1873 headers: None,
1874 };
1875 let mut ctx = PluginContext::new();
1876 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1877 .await
1878 .expect("the round trip succeeds");
1879
1880 assert!(fields.continue_processing);
1881 assert!(fields.violation.is_none());
1882
1883 let task = last_task(&dir);
1885 assert_eq!(task["task_type"], "load_and_run_hook");
1886 assert_eq!(task["hook_type"], "tool_pre_invoke");
1887 assert_eq!(task["plugin_name"], "py-plugin");
1888 assert_eq!(task["class_name"], "my_pkg.Plugin");
1889 assert_eq!(task["payload"]["name"], "search");
1890 assert_eq!(task["payload"]["args"]["q"], "rust");
1891
1892 let config_str = task["config"]
1894 .as_str()
1895 .expect("config must be a JSON string");
1896 let config: serde_json::Value = serde_json::from_str(config_str).unwrap();
1897 assert_eq!(config["name"], "py-plugin");
1898
1899 plugin.shutdown().await.expect("shuts down");
1900 }
1901
1902 #[tokio::test]
1903 async fn a_deny_response_becomes_a_deny_result_with_its_violation() {
1904 if crate::testing::skip_without_python3(
1905 "a_deny_response_becomes_a_deny_result_with_its_violation",
1906 ) {
1907 return;
1908 }
1909 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1910 set_mode(&dir, "deny");
1911
1912 let payload = crate::legacy::ToolPreInvokePayload {
1913 name: "search".into(),
1914 ..Default::default()
1915 };
1916 let mut ctx = PluginContext::new();
1917 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1918 .await
1919 .expect("a deny is a successful invocation that returns a deny");
1920
1921 assert!(!fields.continue_processing);
1922 let violation = fields.violation.expect("a deny must carry its violation");
1923 assert_eq!(violation.code, "PII_DETECTED");
1924 assert_eq!(violation.reason, "email in args");
1925 assert_eq!(violation.details["field"], "q");
1926
1927 plugin.shutdown().await.unwrap();
1928 }
1929
1930 #[tokio::test]
1931 async fn a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police() {
1932 if crate::testing::skip_without_python3(
1935 "a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police",
1936 ) {
1937 return;
1938 }
1939 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1940 set_mode(&dir, "error");
1941
1942 let payload = crate::legacy::ToolPreInvokePayload {
1943 name: "search".into(),
1944 ..Default::default()
1945 };
1946 let mut ctx = PluginContext::new();
1947 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
1948 panic!("a worker error response must not read as success");
1949 };
1950
1951 match *err {
1952 PluginError::Execution {
1953 ref plugin_name,
1954 ref code,
1955 ref message,
1956 ..
1957 } => {
1958 assert_eq!(plugin_name, "py-plugin");
1959 assert_eq!(code.as_deref(), Some("worker_error"));
1960 assert!(
1961 message.contains("ValueError"),
1962 "the plugin's message should survive: {message}"
1963 );
1964 },
1965 ref other => panic!("expected an Execution error, got {other:?}"),
1966 }
1967
1968 plugin.shutdown().await.unwrap();
1969 }
1970
1971 #[tokio::test]
1972 async fn context_deltas_are_written_back_into_the_plugin_context() {
1973 if crate::testing::skip_without_python3(
1974 "context_deltas_are_written_back_into_the_plugin_context",
1975 ) {
1976 return;
1977 }
1978 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1979 set_mode(&dir, "context");
1980
1981 let payload = crate::legacy::ToolPreInvokePayload {
1982 name: "search".into(),
1983 ..Default::default()
1984 };
1985 let mut ctx = PluginContext::new();
1986 ctx.set_local("preexisting", serde_json::json!("kept"));
1987
1988 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1989 .await
1990 .unwrap();
1991
1992 assert_eq!(ctx.get_local("seen"), Some(&serde_json::json!(1)));
1993 assert_eq!(ctx.get_global("shared"), Some(&serde_json::json!("yes")));
1994 assert_eq!(
1995 ctx.get_local("preexisting"),
1996 Some(&serde_json::json!("kept")),
1997 "deltas merge — a plugin touching one key must not blank the rest"
1998 );
1999
2000 plugin.shutdown().await.unwrap();
2001 }
2002
2003 #[tokio::test]
2004 async fn a_modified_payload_comes_back_as_the_hooks_typed_payload() {
2005 if crate::testing::skip_without_python3(
2006 "a_modified_payload_comes_back_as_the_hooks_typed_payload",
2007 ) {
2008 return;
2009 }
2010 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2011 set_mode(&dir, "modify");
2012
2013 let payload = crate::legacy::ToolPreInvokePayload {
2014 name: "search".into(),
2015 ..Default::default()
2016 };
2017 let mut ctx = PluginContext::new();
2018 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2019 .await
2020 .unwrap();
2021
2022 let modified = fields.modified_payload.expect("the modification survives");
2023 let typed = modified
2024 .as_any()
2025 .downcast_ref::<crate::legacy::ToolPreInvokePayload>()
2026 .expect("must come back as the tool payload or the executor's downcast fails");
2027 assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
2028
2029 plugin.shutdown().await.unwrap();
2030 }
2031
2032 #[tokio::test]
2033 async fn a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker() {
2034 if crate::testing::skip_without_python3(
2035 "a_cmf_hook_sends_the_message_payload_shape_to_a_cmf_worker",
2036 ) {
2037 return;
2038 }
2039 let (dir, plugin) = uninitialized_adapter_plugin(&["cmf.tool_pre_invoke"], &[]);
2040 set_handshake_mode(&dir, "cmf");
2042 plugin.initialize().await.expect("the stub worker launches");
2043 set_mode(&dir, "allow");
2044
2045 let payload = ppe::praxis_policy_core::cmf::MessagePayload {
2046 message: ppe::praxis_policy_core::cmf::Message::text(
2047 ppe::praxis_policy_core::cmf::Role::User,
2048 "hello",
2049 ),
2050 };
2051 let mut ctx = PluginContext::new();
2052 invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
2053 .await
2054 .unwrap();
2055
2056 let task = last_task(&dir);
2057 assert_eq!(task["hook_type"], "cmf.tool_pre_invoke");
2058 assert_eq!(
2059 task["payload"]["message"]["role"], "user",
2060 "a cmf hook must send the CMF message payload, not the generic wrapper"
2061 );
2062
2063 plugin.shutdown().await.unwrap();
2064 }
2065
2066 fn cmf_tool_call(q: &str) -> ppe::praxis_policy_core::cmf::MessagePayload {
2069 use ppe::praxis_policy_core::cmf::{ContentPart, Message, Role, ToolCall};
2070 ppe::praxis_policy_core::cmf::MessagePayload {
2071 message: Message::with_content(
2072 Role::Assistant,
2073 vec![
2074 ContentPart::Text {
2075 text: String::new(),
2076 },
2077 ContentPart::ToolCall {
2078 content: ToolCall {
2079 tool_call_id: "call-7".into(),
2080 name: "search".into(),
2081 arguments: std::collections::HashMap::from([(
2082 "q".into(),
2083 serde_json::json!(q),
2084 )]),
2085 namespace: None,
2086 },
2087 },
2088 ],
2089 ),
2090 }
2091 }
2092
2093 #[tokio::test]
2094 async fn a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload() {
2095 if crate::testing::skip_without_python3(
2096 "a_cmf_hook_reaches_an_old_worker_as_the_legacy_payload",
2097 ) {
2098 return;
2099 }
2100 let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
2102 set_mode(&dir, "allow");
2103
2104 let mut ctx = PluginContext::new();
2105 let fields = invoke(
2106 &plugin,
2107 "cmf.tool_pre_invoke",
2108 &cmf_tool_call("rust"),
2109 &mut ctx,
2110 )
2111 .await
2112 .expect("the shimmed round trip succeeds");
2113 assert!(fields.continue_processing);
2114 assert!(fields.modified_payload.is_none());
2115
2116 let task = last_task(&dir);
2117 assert_eq!(task["hook_type"], "tool_pre_invoke");
2118 assert_eq!(
2119 task["payload"],
2120 serde_json::json!({"name": "search", "args": {"q": "rust"}})
2121 );
2122 assert_eq!(
2123 worker_config(&task)["hooks"],
2124 serde_json::json!(["tool_pre_invoke"])
2125 );
2126
2127 plugin.shutdown().await.unwrap();
2128 }
2129
2130 #[tokio::test]
2131 async fn a_legacy_modification_maps_back_onto_the_cmf_message() {
2132 if crate::testing::skip_without_python3(
2133 "a_legacy_modification_maps_back_onto_the_cmf_message",
2134 ) {
2135 return;
2136 }
2137 let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
2138 set_mode(&dir, "modify");
2140
2141 let mut ctx = PluginContext::new();
2142 let fields = invoke(
2143 &plugin,
2144 "cmf.tool_pre_invoke",
2145 &cmf_tool_call("alice@example.com"),
2146 &mut ctx,
2147 )
2148 .await
2149 .unwrap();
2150
2151 let modified = fields.modified_payload.expect("the modification survives");
2152 let message = modified
2153 .as_any()
2154 .downcast_ref::<ppe::praxis_policy_core::cmf::MessagePayload>()
2155 .expect("a cmf hook's modification must come back as a MessagePayload");
2156 let json = serde_json::to_value(message).unwrap();
2157 assert_eq!(json["message"]["content"][0]["text"], "");
2158 assert_eq!(
2159 json["message"]["content"][1]["content"]["arguments"]["q"],
2160 "[REDACTED]"
2161 );
2162 assert_eq!(
2163 json["message"]["content"][1]["content"]["tool_call_id"],
2164 "call-7"
2165 );
2166
2167 plugin.shutdown().await.unwrap();
2168 }
2169
2170 #[tokio::test]
2171 async fn a_legacy_deny_passes_through_the_shim_unchanged() {
2172 if crate::testing::skip_without_python3("a_legacy_deny_passes_through_the_shim_unchanged") {
2173 return;
2174 }
2175 let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
2176 for mode in ["deny", "deny_unmappable"] {
2177 set_mode(&dir, mode);
2178 let mut ctx = PluginContext::new();
2179 let fields = invoke(
2180 &plugin,
2181 "cmf.tool_pre_invoke",
2182 &cmf_tool_call("x"),
2183 &mut ctx,
2184 )
2185 .await
2186 .expect("an unusable modification must not replace a denial with an error");
2187
2188 assert!(!fields.continue_processing);
2189 assert!(fields.modified_payload.is_none());
2190 let violation = fields.violation.expect("the deny keeps its violation");
2191 assert_eq!(violation.code, "PII_DETECTED");
2192 assert_eq!(violation.details["field"], "q");
2193 }
2194
2195 plugin.shutdown().await.unwrap();
2196 }
2197
2198 #[tokio::test]
2199 async fn an_unrepresentable_message_fails_closed_before_reaching_the_worker() {
2200 if crate::testing::skip_without_python3(
2201 "an_unrepresentable_message_fails_closed_before_reaching_the_worker",
2202 ) {
2203 return;
2204 }
2205 let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
2206 set_mode(&dir, "allow");
2207
2208 let no_tool_call = ppe::praxis_policy_core::cmf::MessagePayload {
2209 message: ppe::praxis_policy_core::cmf::Message::text(
2210 ppe::praxis_policy_core::cmf::Role::User,
2211 "hello",
2212 ),
2213 };
2214 for hook in ["cmf.tool_pre_invoke"] {
2215 let mut ctx = PluginContext::new();
2216 let Err(err) = invoke(&plugin, hook, &no_tool_call, &mut ctx).await else {
2217 panic!("'{hook}' must fail closed rather than allow");
2218 };
2219 let message = err.to_string();
2220 assert!(message.contains(hook), "names the hook: {message}");
2221 assert!(message.contains("py-plugin"), "names the plugin: {message}");
2222 match *err {
2223 PluginError::Execution { ref code, .. } => {
2224 assert_eq!(code.as_deref(), Some("legacy_shim_unsupported"));
2225 },
2226 ref other => panic!("expected an Execution error, got {other:?}"),
2227 }
2228 }
2229 assert!(
2230 !dir.path().join("last_task.json").exists(),
2231 "nothing may reach the worker for a message the shim refused"
2232 );
2233
2234 plugin.shutdown().await.unwrap();
2235 }
2236
2237 #[tokio::test]
2238 async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() {
2239 let config = PluginConfig {
2240 name: "uninitialized".into(),
2241 kind: crate::factory::KIND.into(),
2242 hooks: vec!["tool_pre_invoke".into()],
2243 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2244 ..Default::default()
2245 };
2246 let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
2247
2248 let payload = crate::legacy::ToolPreInvokePayload {
2249 name: "search".into(),
2250 ..Default::default()
2251 };
2252 let mut ctx = PluginContext::new();
2253 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
2254 panic!("dispatch without a worker cannot succeed");
2255 };
2256 assert!(matches!(*err, PluginError::Execution { .. }));
2257 }
2258
2259 #[tokio::test]
2260 async fn a_credential_capability_attaches_the_dto_on_an_identity_hook() {
2261 if crate::testing::skip_without_python3(
2264 "a_credential_capability_attaches_the_dto_on_an_identity_hook",
2265 ) {
2266 return;
2267 }
2268 use ppe::praxis_policy_core::extensions::raw_credentials::{
2269 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2270 };
2271
2272 let (dir, plugin) =
2273 adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
2274 set_mode(&dir, "allow");
2275
2276 let mut raw = RawCredentialsExtension::default();
2277 raw.inbound_tokens.insert(
2278 TokenRole::User,
2279 RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
2280 );
2281 let extensions = Extensions {
2282 raw_credentials: Some(Arc::new(raw)),
2283 ..Default::default()
2284 };
2285
2286 let payload = crate::legacy::IdentityResolvePayload::default();
2287 let mut ctx = PluginContext::new();
2288 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
2289 adapter
2290 .invoke(&payload, &extensions, &mut ctx)
2291 .await
2292 .expect("dispatch succeeds");
2293
2294 let task = last_task(&dir);
2295 assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN");
2296 assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
2297 assert_eq!(
2298 task["hook_type"], "identity_resolve",
2299 "the worker registers the identity hook under its own spelling"
2300 );
2301
2302 plugin.shutdown().await.unwrap();
2303 }
2304
2305 #[tokio::test]
2306 async fn a_credential_capability_attaches_the_dto_on_a_delegation_hook() {
2307 if crate::testing::skip_without_python3(
2308 "a_credential_capability_attaches_the_dto_on_a_delegation_hook",
2309 ) {
2310 return;
2311 }
2312 use ppe::praxis_policy_core::extensions::raw_credentials::{
2313 DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken,
2314 };
2315
2316 let (dir, plugin) = adapter_plugin(&["token.delegate"], &["read_delegated_tokens"]).await;
2317 set_mode(&dir, "allow");
2318
2319 let audience = "https://billing.example.com";
2320 let mut raw = RawCredentialsExtension::default();
2321 raw.delegated_tokens.insert(
2322 DelegationKey::new(DelegationMode::OnBehalfOfUser, audience, vec![]),
2323 RawDelegatedToken::new(
2324 "E2E-DELEGATED-TOKEN",
2325 "Authorization",
2326 audience,
2327 vec![],
2328 chrono::Utc::now(),
2329 ),
2330 );
2331 let extensions = Extensions {
2332 raw_credentials: Some(Arc::new(raw)),
2333 ..Default::default()
2334 };
2335
2336 let payload = crate::legacy::TokenDelegatePayload {
2337 target_name: "billing".into(),
2338 target_audience: Some(audience.into()),
2339 ..Default::default()
2340 };
2341 let mut ctx = PluginContext::new();
2342 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "token.delegate");
2343 adapter
2344 .invoke(&payload, &extensions, &mut ctx)
2345 .await
2346 .expect("dispatch succeeds");
2347
2348 let task = last_task(&dir);
2349 assert_eq!(
2350 task["credential"]["delegated"]["token"],
2351 "E2E-DELEGATED-TOKEN"
2352 );
2353 assert_eq!(task["hook_type"], "token_delegate");
2354
2355 plugin.shutdown().await.unwrap();
2356 }
2357
2358 #[tokio::test]
2359 async fn no_capability_means_no_credential_reaches_the_worker() {
2360 if crate::testing::skip_without_python3(
2361 "no_capability_means_no_credential_reaches_the_worker",
2362 ) {
2363 return;
2364 }
2365 use ppe::praxis_policy_core::extensions::raw_credentials::{
2366 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2367 };
2368
2369 let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
2371 set_mode(&dir, "allow");
2372
2373 let mut raw = RawCredentialsExtension::default();
2374 raw.inbound_tokens.insert(
2375 TokenRole::User,
2376 RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
2377 );
2378 let extensions = Extensions {
2379 raw_credentials: Some(Arc::new(raw)),
2380 ..Default::default()
2381 };
2382
2383 let payload = crate::legacy::IdentityResolvePayload::default();
2384 let mut ctx = PluginContext::new();
2385 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
2386 adapter
2387 .invoke(&payload, &extensions, &mut ctx)
2388 .await
2389 .unwrap();
2390
2391 let task = last_task(&dir);
2392 assert!(task.get("credential").is_none(), "no credential field");
2393 assert!(
2394 !serde_json::to_string(&task)
2395 .unwrap()
2396 .contains("E2E-SECRET-TOKEN"),
2397 "no token bytes anywhere in the task the worker received"
2398 );
2399
2400 plugin.shutdown().await.unwrap();
2401 }
2402
2403 fn last_pid(dir: &TempDir) -> u32 {
2406 std::fs::read_to_string(dir.path().join("last_pid"))
2407 .expect("the stub recorded its pid")
2408 .trim()
2409 .parse()
2410 .expect("pid is numeric")
2411 }
2412
2413 async fn await_worker_death(plugin: &Arc<IsolatedPythonPlugin>) {
2419 for _ in 0..200 {
2420 let alive = plugin
2421 .worker
2422 .read()
2423 .await
2424 .as_ref()
2425 .is_some_and(|w| w.is_alive());
2426 if !alive {
2427 return;
2428 }
2429 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2430 }
2431 panic!("the worker never registered as dead");
2432 }
2433
2434 #[tokio::test]
2435 async fn a_dead_worker_is_respawned_on_the_next_invocation() {
2436 if crate::testing::skip_without_python3("a_dead_worker_is_respawned_on_the_next_invocation")
2441 {
2442 return;
2443 }
2444 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2445 set_mode(&dir, "allow");
2446
2447 let payload = crate::legacy::ToolPreInvokePayload {
2448 name: "search".into(),
2449 ..Default::default()
2450 };
2451 let mut ctx = PluginContext::new();
2452
2453 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2455 .await
2456 .expect("the first invocation succeeds");
2457 let original_pid = last_pid(&dir);
2458
2459 set_mode(&dir, "die");
2461 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2462 await_worker_death(&plugin).await;
2463
2464 set_mode(&dir, "allow");
2467 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2468 .await
2469 .expect("a dead worker must be replaced, not left broken");
2470 assert!(fields.continue_processing);
2471
2472 let new_pid = last_pid(&dir);
2473 assert_ne!(
2474 original_pid, new_pid,
2475 "the request must be served by a respawned process"
2476 );
2477
2478 plugin.shutdown().await.unwrap();
2479 }
2480
2481 #[tokio::test]
2482 async fn a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup() {
2483 if crate::testing::skip_without_python3(
2489 "a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup",
2490 ) {
2491 return;
2492 }
2493 let (dir, plugin) =
2494 uninitialized_adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]);
2495 set_handshake_mode(&dir, "unsupported");
2497
2498 let err = plugin
2499 .initialize()
2500 .await
2501 .expect_err("a worker that would drop the credential must not be installed");
2502
2503 let message = err.to_string();
2504 assert!(
2505 message.contains("does not implement credential"),
2506 "the failure must name the missing feature: {message}"
2507 );
2508 assert!(
2509 message.contains("upgrade the cpex package"),
2510 "the failure must tell an operator what to do: {message}"
2511 );
2512
2513 assert!(
2516 plugin.worker.read().await.is_none(),
2517 "a failed handshake must not leave a worker in the slot"
2518 );
2519 }
2520
2521 #[tokio::test]
2522 async fn llm_hooks_require_a_worker_with_native_cmf_support() {
2523 if crate::testing::skip_without_python3(
2524 "llm_hooks_require_a_worker_with_native_cmf_support",
2525 ) {
2526 return;
2527 }
2528 for hook in ["cmf.llm_input", "cmf.llm_output"] {
2529 for handshake in ["no_features", "unsupported"] {
2530 let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]);
2531 set_handshake_mode(&dir, handshake);
2532 let err = plugin
2533 .initialize()
2534 .await
2535 .expect_err("the shim cannot serve an LLM hook");
2536 assert!(err.to_string().contains("cmf_hooks"), "{err}");
2537 assert!(plugin.worker.read().await.is_none());
2538 }
2539 let (dir, plugin) = uninitialized_adapter_plugin(&[hook], &[]);
2540 set_handshake_mode(&dir, "cmf");
2541 plugin
2542 .initialize()
2543 .await
2544 .expect("a native CMF worker can serve an LLM hook");
2545 plugin.shutdown().await.unwrap();
2546 }
2547 }
2548
2549 #[tokio::test]
2550 async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() {
2551 if crate::testing::skip_without_python3(
2556 "a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker",
2557 ) {
2558 return;
2559 }
2560 let (dir, plugin) = uninitialized_adapter_plugin(&["tool_pre_invoke"], &[]);
2561 set_handshake_mode(&dir, "unsupported");
2562
2563 plugin
2564 .initialize()
2565 .await
2566 .expect("a plugin needing no gated field must still start on an old worker");
2567
2568 let payload = crate::legacy::ToolPreInvokePayload {
2570 name: "search".into(),
2571 ..Default::default()
2572 };
2573 let mut ctx = PluginContext::new();
2574 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2575 .await
2576 .expect("the hook still dispatches");
2577 assert!(fields.continue_processing);
2578
2579 plugin.shutdown().await.unwrap();
2580 }
2581
2582 #[tokio::test]
2583 async fn a_worker_answering_the_handshake_with_no_features_also_fails_closed() {
2584 if crate::testing::skip_without_python3(
2589 "a_worker_answering_the_handshake_with_no_features_also_fails_closed",
2590 ) {
2591 return;
2592 }
2593 let (dir, plugin) =
2594 uninitialized_adapter_plugin(&["tool_pre_invoke"], &["read_labels", "append_labels"]);
2595 set_handshake_mode(&dir, "no_features");
2596
2597 let err = plugin
2598 .initialize()
2599 .await
2600 .expect_err("an extensions-declaring plugin needs a worker that delivers the field");
2601
2602 let message = err.to_string();
2603 assert!(
2604 message.contains("does not implement extensions"),
2605 "an extensions capability requires the extensions feature: {message}"
2606 );
2607 }
2608
2609 #[tokio::test]
2610 async fn a_respawned_worker_receives_the_same_capability_handshake() {
2611 if crate::testing::skip_without_python3(
2617 "a_respawned_worker_receives_the_same_capability_handshake",
2618 ) {
2619 return;
2620 }
2621 use ppe::praxis_policy_core::extensions::raw_credentials::{
2622 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2623 };
2624
2625 let (dir, plugin) =
2626 adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
2627 set_mode(&dir, "allow");
2628
2629 let mut raw = RawCredentialsExtension::default();
2630 raw.inbound_tokens.insert(
2631 TokenRole::User,
2632 RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt),
2633 );
2634 let extensions = Extensions {
2635 raw_credentials: Some(Arc::new(raw)),
2636 ..Default::default()
2637 };
2638
2639 let payload = crate::legacy::IdentityResolvePayload::default();
2640 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
2641
2642 let mut ctx = PluginContext::new();
2643 adapter
2644 .invoke(&payload, &extensions, &mut ctx)
2645 .await
2646 .expect("the first dispatch succeeds");
2647 let original_pid = last_pid(&dir);
2648 assert_eq!(
2649 last_task(&dir)["credential"]["inbound"]["token"],
2650 "RESPAWN-SECRET"
2651 );
2652
2653 set_mode(&dir, "die");
2654 let mut ctx = PluginContext::new();
2655 let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2656 await_worker_death(&plugin).await;
2657
2658 set_mode(&dir, "allow");
2660 let mut ctx = PluginContext::new();
2661 adapter
2662 .invoke(&payload, &extensions, &mut ctx)
2663 .await
2664 .expect("dispatch succeeds against the respawned worker");
2665
2666 assert_ne!(original_pid, last_pid(&dir), "a new process served it");
2667 let task = last_task(&dir);
2668 assert_eq!(
2669 task["credential"]["inbound"]["token"], "RESPAWN-SECRET",
2670 "the respawned worker must get the same credential handshake"
2671 );
2672 assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
2673
2674 plugin.shutdown().await.unwrap();
2675 }
2676
2677 #[tokio::test]
2678 async fn a_respawned_worker_gets_no_credential_it_did_not_declare() {
2679 if crate::testing::skip_without_python3(
2682 "a_respawned_worker_gets_no_credential_it_did_not_declare",
2683 ) {
2684 return;
2685 }
2686 use ppe::praxis_policy_core::extensions::raw_credentials::{
2687 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2688 };
2689
2690 let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
2691 set_mode(&dir, "allow");
2692
2693 let mut raw = RawCredentialsExtension::default();
2694 raw.inbound_tokens.insert(
2695 TokenRole::User,
2696 RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt),
2697 );
2698 let extensions = Extensions {
2699 raw_credentials: Some(Arc::new(raw)),
2700 ..Default::default()
2701 };
2702
2703 let payload = crate::legacy::IdentityResolvePayload::default();
2704 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity.resolve");
2705
2706 let mut ctx = PluginContext::new();
2707 adapter
2708 .invoke(&payload, &extensions, &mut ctx)
2709 .await
2710 .unwrap();
2711
2712 set_mode(&dir, "die");
2713 let mut ctx = PluginContext::new();
2714 let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2715 await_worker_death(&plugin).await;
2716
2717 set_mode(&dir, "allow");
2718 let mut ctx = PluginContext::new();
2719 adapter
2720 .invoke(&payload, &extensions, &mut ctx)
2721 .await
2722 .expect("dispatch succeeds after respawn");
2723
2724 let task = last_task(&dir);
2725 assert!(
2726 task.get("credential").is_none(),
2727 "respawn must not widen the capability gate"
2728 );
2729 assert!(
2730 !serde_json::to_string(&task)
2731 .unwrap()
2732 .contains("MUST-NOT-LEAK"),
2733 "no token bytes may reach a worker that declared nothing"
2734 );
2735
2736 plugin.shutdown().await.unwrap();
2737 }
2738
2739 #[tokio::test]
2740 async fn a_worker_that_will_not_stay_up_gives_up_with_a_typed_error() {
2741 if crate::testing::skip_without_python3(
2746 "a_worker_that_will_not_stay_up_gives_up_with_a_typed_error",
2747 ) {
2748 return;
2749 }
2750 let dir = TempDir::new();
2751 let script = dir.path().join("always_dies.py");
2752 std::fs::write(
2754 &script,
2755 "import sys\nprint('fatal: dependency missing', file=sys.stderr, flush=True)\nsys.exit(1)\n",
2756 )
2757 .unwrap();
2758
2759 let config = PluginConfig {
2760 name: "crash-loop".into(),
2761 kind: crate::factory::KIND.into(),
2762 hooks: vec!["tool_pre_invoke".into()],
2763 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2764 ..Default::default()
2765 };
2766 let plugin = Arc::new(
2767 IsolatedPythonPlugin::from_config(&config)
2768 .expect("config parses")
2769 .with_worker_override(script),
2770 );
2771 plugin.initialize().await.expect("the process launches");
2774
2775 let payload = crate::legacy::ToolPreInvokePayload {
2776 name: "search".into(),
2777 ..Default::default()
2778 };
2779
2780 let started = std::time::Instant::now();
2783 let mut gave_up = None;
2784 for _ in 0..(MAX_RESPAWN_ATTEMPTS + 4) {
2785 let mut ctx = PluginContext::new();
2786 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
2787 panic!("a worker that always dies cannot serve a request");
2788 };
2789 let message = err.to_string();
2790 if message.contains("could not be respawned") {
2791 gave_up = Some(message);
2792 break;
2793 }
2794 }
2795
2796 let message = gave_up
2797 .expect("a permanently failing worker must give up with an error, not retry forever");
2798 assert!(
2799 message.contains(&MAX_RESPAWN_ATTEMPTS.to_string()),
2800 "the error should name the attempt bound: {message}"
2801 );
2802
2803 assert!(
2805 started.elapsed() < std::time::Duration::from_secs(20),
2806 "giving up took too long: {:?}",
2807 started.elapsed()
2808 );
2809
2810 plugin.shutdown().await.unwrap();
2811 }
2812
2813 #[tokio::test]
2814 async fn respawn_is_not_attempted_before_initialize() {
2815 let config = PluginConfig {
2818 name: "uninitialized".into(),
2819 kind: crate::factory::KIND.into(),
2820 hooks: vec!["tool_pre_invoke".into()],
2821 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2822 ..Default::default()
2823 };
2824 let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
2825
2826 let Err(err) = plugin.worker().await else {
2827 panic!("a plugin with no worker must not silently spawn one");
2828 };
2829 let message = err.to_string();
2830 assert!(
2831 message.contains("initialize()"),
2832 "the error should name the lifecycle gap: {message}"
2833 );
2834 assert!(
2835 plugin.worker.read().await.is_none(),
2836 "no worker was started"
2837 );
2838 }
2839
2840 #[tokio::test]
2841 async fn concurrent_invocations_share_one_respawn() {
2842 if crate::testing::skip_without_python3("concurrent_invocations_share_one_respawn") {
2846 return;
2847 }
2848 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2849 set_mode(&dir, "allow");
2850
2851 let payload = crate::legacy::ToolPreInvokePayload {
2852 name: "search".into(),
2853 ..Default::default()
2854 };
2855 let mut ctx = PluginContext::new();
2856 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2857 .await
2858 .unwrap();
2859
2860 set_mode(&dir, "die");
2861 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2862 await_worker_death(&plugin).await;
2863 set_mode(&dir, "allow");
2864
2865 let mut handles = Vec::new();
2867 for _ in 0..4 {
2868 let plugin = Arc::clone(&plugin);
2869 handles.push(tokio::spawn(async move {
2870 let payload = crate::legacy::ToolPreInvokePayload {
2871 name: "search".into(),
2872 ..Default::default()
2873 };
2874 let mut ctx = PluginContext::new();
2875 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2876 .await
2877 .map(|f| f.continue_processing)
2878 }));
2879 }
2880
2881 for handle in handles {
2882 assert!(handle.await.unwrap().expect("each invocation recovers"));
2883 }
2884
2885 set_mode(&dir, "die");
2888 let mut ctx = PluginContext::new();
2889 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2890 await_worker_death(&plugin).await;
2891 set_mode(&dir, "allow");
2892
2893 let mut ctx = PluginContext::new();
2894 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2895 .await
2896 .expect("budget was not exhausted by the concurrent respawn");
2897
2898 plugin.shutdown().await.unwrap();
2899 }
2900
2901 fn lines_in(dir: &TempDir, name: &str) -> usize {
2903 std::fs::read_to_string(dir.path().join(name))
2904 .map(|s| s.lines().count())
2905 .unwrap_or(0)
2906 }
2907
2908 #[tokio::test]
2909 async fn a_failed_respawn_is_retried_on_the_next_invocation() {
2910 if crate::testing::skip_without_python3(
2914 "a_failed_respawn_is_retried_on_the_next_invocation",
2915 ) {
2916 return;
2917 }
2918 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &["read_labels"]).await;
2920 set_mode(&dir, "allow");
2921 let payload = crate::legacy::ToolPreInvokePayload {
2922 name: "search".into(),
2923 ..Default::default()
2924 };
2925 let mut ctx = PluginContext::new();
2926 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2927 .await
2928 .unwrap();
2929 let original_pid = last_pid(&dir);
2930
2931 set_mode(&dir, "die");
2932 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2933 await_worker_death(&plugin).await;
2934
2935 set_handshake_mode(&dir, "no_features");
2937 set_mode(&dir, "allow");
2938 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
2939 panic!("a replacement that fails verification must not serve the request");
2940 };
2941 assert!(
2942 err.to_string().contains("does not implement extensions"),
2943 "{err}"
2944 );
2945 assert!(
2946 plugin.worker.read().await.is_some(),
2947 "the dead worker stays in the slot so a later call can respawn again"
2948 );
2949
2950 std::fs::remove_file(dir.path().join("handshake_mode")).unwrap();
2952 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2953 .await
2954 .expect("a later invocation retries the respawn");
2955 assert!(fields.continue_processing);
2956 assert_ne!(original_pid, last_pid(&dir), "served by a new process");
2957
2958 plugin.shutdown().await.unwrap();
2959 }
2960
2961 #[tokio::test]
2962 async fn a_respawn_racing_shutdown_launches_no_worker() {
2963 if crate::testing::skip_without_python3("a_respawn_racing_shutdown_launches_no_worker") {
2967 return;
2968 }
2969 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2970 set_mode(&dir, "allow");
2971 let payload = crate::legacy::ToolPreInvokePayload {
2972 name: "search".into(),
2973 ..Default::default()
2974 };
2975 let mut ctx = PluginContext::new();
2976 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2977 .await
2978 .unwrap();
2979 set_mode(&dir, "die");
2980 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2981 await_worker_death(&plugin).await;
2982 set_mode(&dir, "allow");
2983 assert_eq!(lines_in(&dir, "spawns"), 1);
2984
2985 let racing = {
2986 let plugin = Arc::clone(&plugin);
2987 tokio::spawn(async move {
2988 let payload = crate::legacy::ToolPreInvokePayload {
2989 name: "search".into(),
2990 ..Default::default()
2991 };
2992 let mut ctx = PluginContext::new();
2993 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2994 .await
2995 .map(|_| ())
2996 })
2997 };
2998 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3000 plugin.shutdown().await.unwrap();
3001
3002 let Err(err) = racing.await.unwrap() else {
3003 panic!("an invocation racing shutdown must not be served by a new worker");
3004 };
3005 assert!(err.to_string().contains("shut down"), "{err}");
3006
3007 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
3009 assert_eq!(
3010 lines_in(&dir, "spawns"),
3011 1,
3012 "no worker launched after shutdown"
3013 );
3014 assert!(plugin.worker.read().await.is_none(), "the slot stays empty");
3015
3016 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
3018 panic!("a shut-down plugin cannot serve a request");
3019 };
3020 assert!(err.to_string().contains("shut down"), "{err}");
3021 }
3022
3023 #[tokio::test]
3024 async fn a_transient_probe_failure_is_retried_before_choosing_the_shim() {
3025 if crate::testing::skip_without_python3(
3026 "a_transient_probe_failure_is_retried_before_choosing_the_shim",
3027 ) {
3028 return;
3029 }
3030 let (dir, plugin) = uninitialized_adapter_plugin_with(
3032 &["cmf.tool_pre_invoke"],
3033 &[],
3034 serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }),
3035 );
3036 set_handshake_mode(&dir, "flaky_cmf");
3038 plugin
3039 .initialize()
3040 .await
3041 .expect("a failed probe does not stop a plugin that requires nothing");
3042 set_mode(&dir, "allow");
3043
3044 let payload = ppe::praxis_policy_core::cmf::MessagePayload {
3045 message: ppe::praxis_policy_core::cmf::Message::text(
3046 ppe::praxis_policy_core::cmf::Role::User,
3047 "hello",
3048 ),
3049 };
3050 let mut ctx = PluginContext::new();
3051 invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
3052 .await
3053 .expect("served natively once the retried probe answers");
3054
3055 let task = last_task(&dir);
3056 assert_eq!(
3057 task["hook_type"], "cmf.tool_pre_invoke",
3058 "the retried probe's report chooses native dispatch, not the shim"
3059 );
3060 assert_eq!(lines_in(&dir, "probes"), 2);
3061
3062 plugin.shutdown().await.unwrap();
3063 }
3064
3065 #[tokio::test]
3066 async fn a_probe_that_keeps_failing_is_retried_once_per_worker() {
3067 if crate::testing::skip_without_python3(
3068 "a_probe_that_keeps_failing_is_retried_once_per_worker",
3069 ) {
3070 return;
3071 }
3072 let (dir, plugin) = uninitialized_adapter_plugin_with(
3073 &["cmf.tool_pre_invoke"],
3074 &[],
3075 serde_json::json!({ "class_name": "my_pkg.Plugin", "max_content_size": 4096 }),
3076 );
3077 set_handshake_mode(&dir, "broken");
3078 plugin.initialize().await.unwrap();
3079 set_mode(&dir, "allow");
3080
3081 for _ in 0..3 {
3082 let mut ctx = PluginContext::new();
3083 invoke(
3084 &plugin,
3085 "cmf.tool_pre_invoke",
3086 &cmf_tool_call("rust"),
3087 &mut ctx,
3088 )
3089 .await
3090 .expect("the shim still serves the hook");
3091 assert_eq!(last_task(&dir)["hook_type"], "tool_pre_invoke");
3092 }
3093 assert_eq!(
3094 lines_in(&dir, "probes"),
3095 2,
3096 "one startup probe and one retry, not one per call"
3097 );
3098
3099 plugin.shutdown().await.unwrap();
3100 }
3101
3102 fn ppe_identity() -> ppe::praxis_policy_core::identity::IdentityPayload {
3105 use ppe::praxis_policy_core::identity::{IdentityPayload, TokenSource};
3106 IdentityPayload::new("PPE-IDENTITY-SECRET-TOKEN", TokenSource::Bearer).with_headers(
3107 std::collections::HashMap::from([
3108 (
3109 "authorization".into(),
3110 "Bearer PPE-IDENTITY-SECRET-TOKEN".into(),
3111 ),
3112 ("cookie".into(), "session=PPE-COOKIE-SECRET".into()),
3113 ("x-tenant".into(), "acme".into()),
3114 ]),
3115 )
3116 }
3117
3118 async fn invoke_on(
3119 plugin: &Arc<IsolatedPythonPlugin>,
3120 hook: &'static str,
3121 payload: &dyn PluginPayload,
3122 ) -> ppe::praxis_policy_core::executor::ErasedResultFields {
3123 let mut ctx = PluginContext::new();
3124 invoke(plugin, hook, payload, &mut ctx)
3125 .await
3126 .expect("dispatch succeeds")
3127 }
3128
3129 #[tokio::test]
3130 async fn a_ppe_identity_payload_reaches_the_worker_without_its_credential() {
3131 if crate::testing::skip_without_python3(
3132 "a_ppe_identity_payload_reaches_the_worker_without_its_credential",
3133 ) {
3134 return;
3135 }
3136 let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
3137 set_mode(&dir, "allow");
3138 invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
3139
3140 let task = last_task(&dir);
3141 assert_eq!(task["hook_type"], "identity_resolve");
3142 assert_eq!(task["payload"]["headers"], serde_json::json!({}));
3143 assert!(task.get("credential").is_none());
3144 let wire = serde_json::to_string(&task).unwrap();
3145 assert!(!wire.contains("PPE-IDENTITY-SECRET-TOKEN"), "{wire}");
3146 assert!(!wire.contains("PPE-COOKIE-SECRET"), "{wire}");
3147
3148 plugin.shutdown().await.unwrap();
3149 }
3150
3151 #[tokio::test]
3152 async fn a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential() {
3153 if crate::testing::skip_without_python3(
3154 "a_ppe_identity_payload_gives_a_declaring_plugin_the_gated_credential",
3155 ) {
3156 return;
3157 }
3158 let (dir, plugin) =
3159 adapter_plugin(&["identity.resolve"], &["read_inbound_credentials"]).await;
3160 set_mode(&dir, "identity_result");
3161 let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
3162
3163 let task = last_task(&dir);
3164 assert_eq!(
3165 task["credential"]["inbound"]["token"],
3166 "PPE-IDENTITY-SECRET-TOKEN"
3167 );
3168 assert!(
3169 !serde_json::to_string(&task["payload"])
3170 .unwrap()
3171 .contains("PPE-IDENTITY-SECRET-TOKEN"),
3172 "the token rides only the gated channel"
3173 );
3174
3175 let modified = fields
3177 .modified_payload
3178 .expect("the result is a modification");
3179 let identity = modified
3180 .as_any()
3181 .downcast_ref::<ppe::praxis_policy_core::identity::IdentityPayload>()
3182 .expect("identity.resolve must come back as PPE's IdentityPayload");
3183 assert_eq!(
3184 identity.subject.as_ref().and_then(|s| s.id.as_deref()),
3185 Some("alice")
3186 );
3187 assert_eq!(identity.raw_token(), "PPE-IDENTITY-SECRET-TOKEN");
3188
3189 plugin.shutdown().await.unwrap();
3190 }
3191
3192 #[tokio::test]
3193 async fn a_rejected_identity_result_becomes_a_deny() {
3194 if crate::testing::skip_without_python3("a_rejected_identity_result_becomes_a_deny") {
3195 return;
3196 }
3197 let (dir, plugin) = adapter_plugin(&["identity.resolve"], &[]).await;
3198 set_mode(&dir, "identity_rejected");
3199 let fields = invoke_on(&plugin, "identity.resolve", &ppe_identity()).await;
3200 assert!(!fields.continue_processing);
3201 assert!(fields.modified_payload.is_none());
3202 assert_eq!(fields.violation.unwrap().reason, "token expired");
3203
3204 plugin.shutdown().await.unwrap();
3205 }
3206
3207 #[tokio::test]
3208 async fn a_ppe_delegation_payload_round_trips_through_the_worker() {
3209 if crate::testing::skip_without_python3(
3210 "a_ppe_delegation_payload_round_trips_through_the_worker",
3211 ) {
3212 return;
3213 }
3214 use ppe::praxis_policy_core::delegation::DelegationPayload;
3215
3216 let (dir, plugin) =
3218 adapter_plugin(&["token.delegate"], &["read_inbound_credentials"]).await;
3219 set_mode(&dir, "delegation_result");
3220 let payload = DelegationPayload::new("PPE-CALLER-BEARER", "billing")
3221 .with_target_audience("https://billing.example.com");
3222 let fields = invoke_on(&plugin, "token.delegate", &payload).await;
3223
3224 let task = last_task(&dir);
3225 assert_eq!(task["hook_type"], "token_delegate");
3226 assert_eq!(task["payload"]["target_name"], "billing");
3227 assert!(task["payload"].get("bearer_token").is_none());
3228 assert_eq!(
3229 task["credential"]["delegated"]["token"],
3230 "PPE-CALLER-BEARER"
3231 );
3232
3233 let modified = fields
3234 .modified_payload
3235 .expect("the result is a modification");
3236 let delegation = modified
3237 .as_any()
3238 .downcast_ref::<DelegationPayload>()
3239 .expect("token.delegate must come back as PPE's DelegationPayload");
3240 let minted = delegation.delegated_token.as_ref().expect("a minted token");
3241 assert_eq!(minted.token.as_str(), "STUB-MINTED-TOKEN");
3242 assert_eq!(minted.audience, "https://billing.example.com");
3243
3244 plugin.shutdown().await.unwrap();
3245 }
3246
3247 #[test]
3248 fn the_respawn_budget_is_bounded_then_resets_with_time() {
3249 let mut state = RespawnState::new();
3252 for expected in 1..=MAX_RESPAWN_ATTEMPTS {
3253 assert_eq!(state.try_reserve(), Some(expected));
3254 }
3255 assert_eq!(
3256 state.try_reserve(),
3257 None,
3258 "the budget must be bounded, not unlimited"
3259 );
3260
3261 state.record_success();
3263 assert_eq!(state.try_reserve(), Some(1));
3264
3265 let mut state = RespawnState::new();
3268 while state.try_reserve().is_some() {}
3269 state.window_started = Some(
3270 std::time::Instant::now()
3271 - std::time::Duration::from_secs(RESPAWN_BUDGET_RESET_SECS + 1),
3272 );
3273 assert_eq!(
3274 state.try_reserve(),
3275 Some(1),
3276 "an elapsed window must restore the budget"
3277 );
3278 }
3279
3280 #[test]
3281 fn unknown_config_keys_are_tolerated() {
3282 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
3286 "class_name": "my_pkg.Minimal",
3287 "some_python_only_setting": true,
3288 })))
3289 .expect("extra keys belong to the Python plugin, not this host");
3290 assert_eq!(cfg.class_name, "my_pkg.Minimal");
3291 }
3292}