1use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use cpex_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::venv::VenvManager;
33use crate::worker::WorkerClient;
34use crate::{conversion, credentials};
35
36const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
42
43const DEFAULT_TIMEOUT_SECS: u64 = 30;
49
50const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
55
56const MAX_RESPAWN_ATTEMPTS: u32 = 3;
65
66const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
73
74const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
82
83pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
99
100fn project_root() -> PathBuf {
108 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
112}
113
114fn default_plugin_dirs() -> Vec<String> {
120 vec![project_root()
121 .join(DEFAULT_PLUGIN_DIR)
122 .display()
123 .to_string()]
124}
125
126pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
133
134const WORKER_KNOWN_CAPABILITIES: &[&str] = &[
148 "read_subject",
149 "read_roles",
150 "read_teams",
151 "read_claims",
152 "read_permissions",
153 "read_agent",
154 "read_headers",
155 "write_headers",
156 "read_labels",
157 "append_labels",
158 "read_delegation",
159 "append_delegation",
160];
161
162#[derive(Debug, Clone, Deserialize)]
177pub struct VenvConfig {
178 pub class_name: String,
182
183 #[serde(default)]
188 pub requirements_file: Option<String>,
189
190 #[serde(default = "default_script_path")]
193 pub script_path: String,
194
195 #[serde(default = "default_max_content_size")]
197 pub max_content_size: usize,
198
199 #[serde(default = "default_timeout_secs")]
202 pub timeout_secs: u64,
203}
204
205fn default_script_path() -> String {
206 DEFAULT_SCRIPT_PATH.to_string()
207}
208
209fn default_max_content_size() -> usize {
210 DEFAULT_MAX_CONTENT_SIZE
211}
212
213fn default_timeout_secs() -> u64 {
214 DEFAULT_TIMEOUT_SECS
215}
216
217impl VenvConfig {
218 pub fn from_plugin_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
223 let raw = config
224 .config
225 .clone()
226 .unwrap_or_else(|| serde_json::json!({}));
227
228 serde_json::from_value(raw).map_err(|e| {
229 PluginError::Config {
230 message: format!(
231 "plugin '{}' (isolated_venv): invalid `config:` block — {}. \
232 `class_name` is required (the fully-qualified Python class, \
233 e.g. my_pkg.filters.PiiFilter)",
234 config.name, e
235 ),
236 }
237 .boxed()
238 })
239 }
240}
241
242pub struct IsolatedPythonPlugin {
244 config: PluginConfig,
246
247 venv_config: VenvConfig,
249
250 plugin_dirs: Vec<String>,
258
259 venv: Option<VenvManager>,
263
264 worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
266
267 worker_override: Option<PathBuf>,
270
271 worker_cwd: Option<PathBuf>,
279
280 respawn: tokio::sync::Mutex<RespawnState>,
283}
284
285#[derive(Debug)]
292struct RespawnState {
293 attempts: u32,
295 window_started: Option<std::time::Instant>,
298}
299
300impl RespawnState {
301 fn new() -> Self {
302 Self {
303 attempts: 0,
304 window_started: None,
305 }
306 }
307
308 fn try_reserve(&mut self) -> Option<u32> {
313 let now = std::time::Instant::now();
314 let expired = self
315 .window_started
316 .is_some_and(|s| now.duration_since(s).as_secs() >= RESPAWN_BUDGET_RESET_SECS);
317
318 if expired {
319 self.attempts = 0;
320 self.window_started = None;
321 }
322
323 if self.attempts >= MAX_RESPAWN_ATTEMPTS {
324 return None;
325 }
326
327 self.attempts += 1;
328 self.window_started.get_or_insert(now);
329 Some(self.attempts)
330 }
331
332 fn record_success(&mut self) {
334 self.attempts = 0;
335 self.window_started = None;
336 }
337}
338
339impl IsolatedPythonPlugin {
340 pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
342 let venv_config = VenvConfig::from_plugin_config(config)?;
343
344 let plugin_dirs = default_plugin_dirs();
348
349 let venv = VenvManager::new(
354 &plugin_dirs,
355 &venv_config.class_name,
356 venv_config.requirements_file.as_deref(),
357 config.version.as_deref(),
358 )
359 .ok();
360
361 Ok(Self {
362 config: config.clone(),
363 venv_config,
364 plugin_dirs,
365 venv,
366 worker: tokio::sync::RwLock::new(None),
367 worker_override: None,
368 worker_cwd: None,
369 respawn: tokio::sync::Mutex::new(RespawnState::new()),
370 })
371 }
372
373 pub fn plugin_dirs(&self) -> &[String] {
375 &self.plugin_dirs
376 }
377
378 #[cfg(any(test, feature = "testing"))]
385 pub fn with_worker_cwd(mut self, cwd: PathBuf) -> Self {
386 self.worker_cwd = Some(cwd);
387 self
388 }
389
390 #[cfg(any(test, feature = "testing"))]
401 pub fn with_plugin_dirs(mut self, dirs: Vec<String>) -> Self {
402 self.venv = VenvManager::new(
403 &dirs,
404 &self.venv_config.class_name,
405 self.venv_config.requirements_file.as_deref(),
406 self.config.version.as_deref(),
407 )
408 .ok();
409 self.plugin_dirs = dirs;
410 self
411 }
412
413 #[cfg(any(test, feature = "testing"))]
420 pub fn with_worker_override(mut self, script: PathBuf) -> Self {
421 self.worker_override = Some(script);
422 self
423 }
424
425 pub fn venv_config(&self) -> &VenvConfig {
427 &self.venv_config
428 }
429
430 #[cfg(any(test, feature = "testing"))]
434 pub async fn worker_stderr(&self) -> Vec<String> {
435 match self.worker.read().await.as_ref() {
436 Some(worker) => worker.stderr_lines().await,
437 None => Vec::new(),
438 }
439 }
440
441 async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
453 let (python, script) = match self.worker_override.as_ref() {
456 Some(script) => (which_python3()?, script.clone()),
457 None => {
458 let venv = self.venv.as_ref().ok_or_else(|| HostError::Config {
459 message: format!(
460 "could not resolve a venv layout under {} — check that `class_name` \
461 ('{}') begins with a package segment",
462 self.plugin_dirs.join(", "),
463 self.venv_config.class_name,
464 ),
465 })?;
466
467 venv.ensure().await?;
468 let script = crate::worker::resolve_worker_script(
469 &venv.layout().venv_path,
470 &self.venv_config.script_path,
471 )?;
472
473 (venv.python_executable(), script)
474 },
475 };
476
477 WorkerClient::spawn(
482 &python,
483 &script,
484 self.worker_cwd.as_deref(),
485 self.venv_config.max_content_size,
486 self.venv_config.timeout_secs,
487 )
488 .await
489 }
490
491 async fn worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
503 let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
504
505 if let Some(worker) = self.worker.read().await.clone() {
508 if worker.is_alive() {
509 return Ok(worker);
510 }
511 } else {
512 return Err(to_plugin_error(HostError::WorkerStart {
517 message: "plugin has no worker — initialize() did not run or it failed".into(),
518 }));
519 }
520
521 self.respawn_worker().await
522 }
523
524 async fn respawn_worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
531 let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
532 let mut respawn = self.respawn.lock().await;
533
534 if let Some(worker) = self.worker.read().await.clone() {
537 if worker.is_alive() {
538 return Ok(worker);
539 }
540 }
541
542 let Some(attempt) = respawn.try_reserve() else {
543 tracing::error!(
544 plugin = %self.config.name,
545 max_attempts = MAX_RESPAWN_ATTEMPTS,
546 "worker died and the respawn budget is exhausted; giving up"
547 );
548 return Err(to_plugin_error(HostError::WorkerStart {
549 message: format!(
550 "worker died and could not be respawned after {MAX_RESPAWN_ATTEMPTS} attempts \
551 — the plugin's worker is failing to start (check its venv and dependencies)"
552 ),
553 }));
554 };
555
556 let backoff = std::time::Duration::from_millis(
559 RESPAWN_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1)),
560 );
561 tracing::warn!(
562 plugin = %self.config.name,
563 attempt,
564 max_attempts = MAX_RESPAWN_ATTEMPTS,
565 backoff_ms = backoff.as_millis(),
566 "worker is dead; respawning"
567 );
568 tokio::time::sleep(backoff).await;
569
570 if let Some(dead) = self.worker.write().await.take() {
573 if let Err(e) = dead.shutdown().await {
574 tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
575 }
576 }
577
578 let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
580
581 self.verify_worker_understands(&worker)
586 .await
587 .map_err(to_plugin_error)?;
588
589 *self.worker.write().await = Some(Arc::clone(&worker));
590
591 tracing::info!(
604 plugin = %self.config.name,
605 attempt,
606 "worker respawned; awaiting proof it can serve a request"
607 );
608
609 Ok(worker)
610 }
611
612 async fn note_worker_healthy(&self) {
619 let mut respawn = self.respawn.lock().await;
620 if respawn.attempts > 0 {
621 respawn.record_success();
622 }
623 }
624
625 fn worker_capabilities(&self) -> Vec<String> {
644 let (known, unknown): (Vec<String>, Vec<String>) = self
645 .config
646 .capabilities
647 .iter()
648 .cloned()
649 .partition(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
650
651 if !unknown.is_empty() {
652 tracing::warn!(
656 plugin = %self.config.name,
657 dropped = ?unknown,
658 "the installed cpex framework does not model these capabilities; \
659 omitting them from the worker's config so it can still validate"
660 );
661 }
662
663 known
664 }
665
666 async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
684 let required = self.required_worker_features();
685 if required.is_empty() {
686 return Ok(());
687 }
688
689 let reported = worker.capabilities().await?;
690 let missing: Vec<&str> = required
691 .iter()
692 .copied()
693 .filter(|f| !reported.has(f))
694 .collect();
695
696 if missing.is_empty() {
697 tracing::debug!(
698 plugin = %self.config.name,
699 protocol_version = reported.protocol_version,
700 cpex_version = ?reported.cpex_version,
701 "worker handshake satisfied"
702 );
703 return Ok(());
704 }
705
706 let mut declared: Vec<&str> = self
710 .config
711 .capabilities
712 .iter()
713 .map(String::as_str)
714 .collect();
715 declared.sort_unstable();
716
717 Err(HostError::Credential {
718 message: format!(
719 "the installed cpex worker does not implement {} — which plugin '{}' needs given \
720 its declared capabilities ({}) and hooks ({}). The worker reported protocol \
721 version {} and features [{}] (cpex {}). Running anyway would drop those fields \
722 silently, so this plugin is failed closed at startup; upgrade the cpex package \
723 in the plugin's venv.",
724 missing.join(", "),
725 self.config.name,
726 declared.join(", "),
727 self.config.hooks.join(", "),
728 reported.protocol_version,
729 reported
730 .features
731 .iter()
732 .map(String::as_str)
733 .collect::<Vec<_>>()
734 .join(", "),
735 reported
736 .cpex_version
737 .as_deref()
738 .unwrap_or("version unknown"),
739 ),
740 })
741 }
742
743 fn required_worker_features(&self) -> Vec<&'static str> {
749 let mut required = Vec::new();
750
751 let declares_credential_cap = self.config.capabilities.iter().any(|c| {
755 c == crate::credentials::CAP_READ_INBOUND || c == crate::credentials::CAP_READ_DELEGATED
756 });
757 if declares_credential_cap {
758 required.push(crate::worker::FEATURE_CREDENTIAL);
759 }
760
761 let declares_extension_cap = self
766 .config
767 .capabilities
768 .iter()
769 .any(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
770 if declares_extension_cap {
771 required.push(crate::worker::FEATURE_EXTENSIONS);
772 }
773
774 required
775 }
776
777 fn build_task(
785 &self,
786 hook_name: &str,
787 payload: serde_json::Value,
788 ctx: &PluginContext,
789 ) -> Result<serde_json::Value, HostError> {
790 let config_json = serde_json::json!({
803 "name": self.config.name,
804 "kind": self.config.kind,
805 "hooks": self.config.hooks,
806 "capabilities": self.worker_capabilities(),
807 "config": self.config.config.clone().unwrap_or(serde_json::Value::Null),
808 });
809 let config_string =
810 serde_json::to_string(&config_json).map_err(|e| HostError::Protocol {
811 message: format!("could not serialize the plugin config for the worker: {e}"),
812 })?;
813
814 let request_id = ctx
819 .get_global(GLOBAL_REQUEST_ID_KEY)
820 .and_then(serde_json::Value::as_str)
821 .map(str::to_string)
822 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
823
824 Ok(serde_json::json!({
825 "task_type": crate::worker::TASK_LOAD_AND_RUN_HOOK,
826 "plugin_dirs": self.plugin_dirs,
827 "class_name": self.venv_config.class_name,
828 "config": config_string,
829 "hook_type": hook_name,
830 "plugin_name": self.config.name,
831 "payload": payload,
832 "context": {
833 "state": ctx.local_state,
837 "global_context": {
838 "request_id": request_id,
846 "state": ctx.global_state,
847 },
848 "metadata": {},
849 },
850 }))
851 }
852}
853
854#[async_trait]
855impl Plugin for IsolatedPythonPlugin {
856 fn config(&self) -> &PluginConfig {
857 &self.config
858 }
859
860 async fn initialize(&self) -> Result<(), Box<PluginError>> {
865 let worker = self
866 .launch_worker()
867 .await
868 .map_err(|e| e.into_plugin_error(&self.config.name))?;
869
870 self.verify_worker_understands(&worker)
876 .await
877 .map_err(|e| e.into_plugin_error(&self.config.name))?;
878
879 *self.worker.write().await = Some(Arc::new(worker));
880 Ok(())
881 }
882
883 async fn shutdown(&self) -> Result<(), Box<PluginError>> {
885 let worker = self.worker.write().await.take();
886 let result = match worker {
887 Some(worker) => worker
888 .shutdown()
889 .await
890 .map_err(|e| e.into_plugin_error(&self.config.name)),
891 None => Ok(()),
892 };
893
894 if let Some(venv) = self.venv.as_ref() {
899 if let Err(e) = venv.release_owner().await {
900 tracing::warn!(
901 plugin = %self.config.name,
902 "could not release the venv owner claim: {e}"
903 );
904 }
905 }
906
907 result
908 }
909}
910
911fn which_python3() -> Result<PathBuf, HostError> {
917 let candidates = [
918 "/usr/bin/python3",
919 "/usr/local/bin/python3",
920 "/opt/homebrew/bin/python3",
921 ];
922 if let Some(found) = candidates.iter().map(Path::new).find(|p| p.exists()) {
923 return Ok(found.to_path_buf());
924 }
925
926 let output = std::process::Command::new("sh")
928 .args(["-c", "command -v python3"])
929 .output()
930 .map_err(|e| HostError::WorkerStart {
931 message: format!("could not look up python3: {e}"),
932 })?;
933
934 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
935 if path.is_empty() {
936 return Err(HostError::WorkerStart {
937 message: "python3 was not found on PATH".into(),
938 });
939 }
940 Ok(PathBuf::from(path))
941}
942
943pub struct PythonHookAdapter {
948 plugin: Arc<IsolatedPythonPlugin>,
949 hook_name: &'static str,
950}
951
952impl PythonHookAdapter {
953 pub fn new(plugin: Arc<IsolatedPythonPlugin>, hook_name: &'static str) -> Self {
954 Self { plugin, hook_name }
955 }
956}
957
958#[async_trait]
959impl AnyHookHandler for PythonHookAdapter {
960 async fn invoke(
967 &self,
968 payload: &dyn PluginPayload,
969 extensions: &Extensions,
970 ctx: &mut PluginContext,
971 ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
972 let plugin_name = &self.plugin.config.name;
973 let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name);
974
975 let payload_json = conversion::serialize_payload(payload).map_err(to_plugin_error)?;
976 let mut task = self
977 .plugin
978 .build_task(self.hook_name, payload_json, ctx)
979 .map_err(to_plugin_error)?;
980
981 credentials::attach_credential(
984 &mut task,
985 self.hook_name,
986 extensions,
987 &self.plugin.config.capabilities,
988 )
989 .map_err(to_plugin_error)?;
990
991 crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?;
995
996 let worker = self.plugin.worker().await?;
997 let response = worker.send_task(task).await.map_err(to_plugin_error)?;
998
999 self.plugin.note_worker_healthy().await;
1002
1003 apply_context_deltas(&response, ctx);
1006
1007 let fields = conversion::response_to_result(self.hook_name, response, extensions)
1012 .map_err(to_plugin_error)?;
1013 Ok(Box::new(fields))
1014 }
1015
1016 fn hook_type_name(&self) -> &'static str {
1017 self.hook_name
1018 }
1019}
1020
1021fn apply_context_deltas(response: &serde_json::Value, ctx: &mut PluginContext) {
1027 let Some(context) = response.get("context") else {
1028 return;
1029 };
1030
1031 if let Some(state) = context.get("state").and_then(|v| v.as_object()) {
1032 for (key, value) in state {
1033 ctx.local_state.insert(key.clone(), value.clone());
1034 }
1035 }
1036
1037 if let Some(global) = context
1038 .get("global_context")
1039 .and_then(|g| g.get("state"))
1040 .and_then(|v| v.as_object())
1041 {
1042 for (key, value) in global {
1043 ctx.global_state.insert(key.clone(), value.clone());
1044 }
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use crate::testing::TempDir;
1051
1052 use super::*;
1053
1054 fn config_with(value: serde_json::Value) -> PluginConfig {
1055 PluginConfig {
1056 name: "py-plugin".into(),
1057 kind: crate::factory::KIND.into(),
1058 hooks: vec!["tool_pre_invoke".into()],
1059 config: Some(value),
1060 ..Default::default()
1061 }
1062 }
1063
1064 #[test]
1065 fn full_config_block_deserializes_every_field() {
1066 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1067 "class_name": "my_pkg.filters.PiiFilter",
1068 "requirements_file": "requirements.txt",
1069 "plugin_dirs": ["./plugins", "./vendor"],
1070 "script_path": "custom/worker.py",
1071 "max_content_size": 2048,
1072 "timeout_secs": 5,
1073 })))
1074 .expect("a fully populated block parses");
1075
1076 assert_eq!(cfg.class_name, "my_pkg.filters.PiiFilter");
1077 assert_eq!(cfg.requirements_file.as_deref(), Some("requirements.txt"));
1078 assert_eq!(cfg.script_path, "custom/worker.py");
1079 assert_eq!(cfg.max_content_size, 2048);
1080 assert_eq!(cfg.timeout_secs, 5);
1081 }
1085
1086 #[test]
1087 fn absent_optionals_fall_back_to_documented_defaults() {
1088 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1089 "class_name": "my_pkg.Minimal",
1090 })))
1091 .expect("class_name alone is a valid block");
1092
1093 assert_eq!(
1096 cfg.max_content_size, 10_000_000,
1097 "matches client.py's max_content_size"
1098 );
1099 assert_eq!(
1100 cfg.timeout_secs, 30,
1101 "matches venv_comm.py's send_task timeout"
1102 );
1103 assert_eq!(cfg.script_path, "cpex/framework/isolated/worker.py");
1104
1105 assert!(
1106 cfg.requirements_file.is_none(),
1107 "no requirements file is valid"
1108 );
1109 }
1110
1111 #[test]
1112 fn missing_class_name_errors() {
1113 let err = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1114 "plugin_dirs": ["./plugins"],
1115 })))
1116 .expect_err("class_name is required");
1117
1118 assert!(matches!(*err, PluginError::Config { .. }));
1119 assert!(err.to_string().contains("class_name"));
1120 }
1121
1122 #[test]
1123 fn absent_config_block_errors_rather_than_panicking() {
1124 let cfg = PluginConfig {
1125 name: "no-config".into(),
1126 kind: crate::factory::KIND.into(),
1127 hooks: vec!["tool_pre_invoke".into()],
1128 config: None,
1129 ..Default::default()
1130 };
1131
1132 let err = VenvConfig::from_plugin_config(&cfg)
1133 .expect_err("an absent block cannot supply class_name");
1134 assert!(matches!(*err, PluginError::Config { .. }));
1135 }
1136
1137 fn expected_default_dir() -> String {
1140 std::env::current_dir()
1141 .unwrap()
1142 .join("plugins")
1143 .display()
1144 .to_string()
1145 }
1146
1147 #[test]
1148 fn plugin_dirs_default_to_plugins_at_the_project_root() {
1149 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1151 "class_name": "my_pkg.Minimal",
1152 })))
1153 .expect("class_name alone is a valid block");
1154
1155 assert_eq!(
1156 plugin.plugin_dirs(),
1157 [expected_default_dir()],
1158 "the host supplies the plugin dir; the config no longer can"
1159 );
1160 assert!(
1163 Path::new(&plugin.plugin_dirs()[0]).is_absolute(),
1164 "the resolved dir must be absolute: {:?}",
1165 plugin.plugin_dirs()
1166 );
1167 }
1168
1169 #[test]
1170 fn a_plugin_dirs_key_in_the_config_block_is_ignored() {
1171 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1175 "class_name": "my_pkg.Minimal",
1176 "plugin_dirs": ["/somewhere/else", "/and/here"],
1177 })))
1178 .expect("an ignored key must not fail the parse");
1179
1180 assert_eq!(
1181 plugin.plugin_dirs(),
1182 [expected_default_dir()],
1183 "`plugin_dirs` in the config block must be ignored"
1184 );
1185 }
1186
1187 #[test]
1188 fn the_top_level_plugin_dirs_key_is_also_ignored() {
1189 let yaml = r#"
1192plugin_dirs: ["./top-level-dirs"]
1193plugins:
1194 - name: py-plugin
1195 kind: isolated_venv
1196 hooks: [tool_pre_invoke]
1197 config:
1198 class_name: my_pkg.Minimal
1199"#;
1200 let cpex_config: cpex_core::config::CpexConfig =
1201 serde_yaml::from_str(yaml).expect("valid YAML");
1202 assert_eq!(
1203 cpex_config.plugin_dirs,
1204 vec!["./top-level-dirs"],
1205 "the top-level key still parses — it is simply not the host's source"
1206 );
1207
1208 let plugin =
1209 IsolatedPythonPlugin::from_config(&cpex_config.plugins[0]).expect("config parses");
1210 assert_eq!(
1211 plugin.plugin_dirs(),
1212 [expected_default_dir()],
1213 "the top-level key must not steer the host's plugin dir"
1214 );
1215 }
1216
1217 #[test]
1218 fn the_resolved_plugin_dir_is_what_the_worker_receives() {
1219 let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1223 "class_name": "my_pkg.Minimal",
1224 "plugin_dirs": ["/ignored"],
1225 })))
1226 .expect("parses");
1227
1228 let task = plugin
1229 .build_task(
1230 "tool_pre_invoke",
1231 serde_json::json!({"name": "t"}),
1232 &PluginContext::new(),
1233 )
1234 .expect("task builds");
1235
1236 assert_eq!(
1237 task["plugin_dirs"],
1238 serde_json::json!([expected_default_dir()]),
1239 "the worker must be told the resolved dir, not the ignored one"
1240 );
1241 }
1242
1243 fn worker_config(task: &serde_json::Value) -> serde_json::Value {
1245 serde_json::from_str(task["config"].as_str().expect("config is a string"))
1246 .expect("the worker's config parses")
1247 }
1248
1249 fn task_for_capabilities(capabilities: &[&str]) -> serde_json::Value {
1251 let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
1252 config.capabilities = capabilities.iter().map(|c| (*c).to_string()).collect();
1253
1254 IsolatedPythonPlugin::from_config(&config)
1255 .expect("parses")
1256 .build_task(
1257 "tool_pre_invoke",
1258 serde_json::json!({"name": "t"}),
1259 &PluginContext::new(),
1260 )
1261 .expect("task builds")
1262 }
1263
1264 #[test]
1265 fn declared_capabilities_reach_the_workers_config() {
1266 let config = worker_config(&task_for_capabilities(&["read_labels", "append_labels"]));
1272
1273 let mut sent: Vec<&str> = config["capabilities"]
1274 .as_array()
1275 .expect("capabilities is an array")
1276 .iter()
1277 .map(|c| c.as_str().expect("a string"))
1278 .collect();
1279 sent.sort_unstable();
1280
1281 assert_eq!(
1282 sent,
1283 ["append_labels", "read_labels"],
1284 "the worker must be told what the plugin declared"
1285 );
1286 }
1287
1288 #[test]
1289 fn a_capability_the_python_model_rejects_is_not_forwarded() {
1290 let config = worker_config(&task_for_capabilities(&["read_labels", "read_client"]));
1296
1297 assert_eq!(
1298 config["capabilities"],
1299 serde_json::json!(["read_labels"]),
1300 "a name the installed framework cannot validate must be dropped, \
1301 not sent and not escalated into a task failure"
1302 );
1303 }
1304
1305 #[test]
1306 fn a_plugin_declaring_nothing_sends_an_empty_capability_list() {
1307 let config = worker_config(&task_for_capabilities(&[]));
1312
1313 assert_eq!(
1314 config["capabilities"],
1315 serde_json::json!([]),
1316 "no capabilities is an empty list, not a missing field"
1317 );
1318 }
1319
1320 const ADAPTER_STUB: &str = r#"
1328import json, os, sys
1329
1330here = os.path.dirname(os.path.abspath(__file__))
1331
1332def mode_file(name, default=""):
1333 try:
1334 with open(os.path.join(here, name)) as f:
1335 return f.read().strip()
1336 except FileNotFoundError:
1337 return default
1338
1339def mode():
1340 return mode_file("mode", "allow")
1341
1342while True:
1343 line = sys.stdin.readline()
1344 if not line:
1345 break
1346 line = line.strip()
1347 if not line:
1348 continue
1349 task = json.loads(line)
1350 rid = task.get("request_id", "unknown")
1351
1352 if task.get("task_type") == "shutdown":
1353 print(json.dumps({"status": "success", "request_id": rid}), flush=True)
1354 break
1355
1356 # Answered before the recording below, so the spawn-time handshake does not
1357 # overwrite the `last_task.json` the tests assert their hook task against.
1358 # This stub stands in for a *current* worker, so it claims the features the
1359 # real one implements; `handshake_mode` covers the old-worker case.
1360 if task.get("task_type") == "capabilities":
1361 hs = mode_file("handshake_mode")
1362 if hs == "unsupported":
1363 # Exactly what a worker predating the handshake replies.
1364 print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True)
1365 continue
1366 features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"]
1367 print(json.dumps({
1368 "status": "success", "protocol_version": 1,
1369 "features": features, "cpex_version": "0.1.2", "request_id": rid,
1370 }), flush=True)
1371 continue
1372
1373 # Record the task so the test can assert on what the host actually sent.
1374 with open(os.path.join(here, "last_task.json"), "w") as f:
1375 json.dump(task, f)
1376
1377 # ...and which process served it, so a respawn test can prove the request
1378 # was answered by a *new* worker rather than by the original recovering.
1379 with open(os.path.join(here, "last_pid"), "w") as f:
1380 f.write(str(os.getpid()))
1381
1382 m = mode()
1383
1384 # `die` exits without replying, which is how a test kills the worker so the
1385 # *next* invocation has a dead one to respawn.
1386 if m == "die":
1387 sys.exit(9)
1388
1389 if m == "deny":
1390 response = {
1391 "continue_processing": False,
1392 "violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}},
1393 }
1394 elif m == "error":
1395 response = {"status": "error", "message": "the plugin raised ValueError"}
1396 elif m == "context":
1397 response = {
1398 "continue_processing": True,
1399 "context": {"state": {"seen": 1}, "global_context": {"state": {"shared": "yes"}}},
1400 }
1401 elif m == "modify":
1402 response = {
1403 "continue_processing": True,
1404 "modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}},
1405 }
1406 else:
1407 response = {"continue_processing": True}
1408
1409 response["request_id"] = rid
1410 print(json.dumps(response), flush=True)
1411"#;
1412
1413 async fn adapter_plugin(
1415 hooks: &[&str],
1416 capabilities: &[&str],
1417 ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1418 let (dir, plugin) = uninitialized_adapter_plugin(hooks, capabilities);
1419 plugin.initialize().await.expect("the stub worker launches");
1420 (dir, plugin)
1421 }
1422
1423 fn set_mode(dir: &TempDir, mode: &str) {
1424 std::fs::write(dir.path().join("mode"), mode).unwrap();
1425 }
1426
1427 fn set_handshake_mode(dir: &TempDir, mode: &str) {
1432 std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
1433 }
1434
1435 fn uninitialized_adapter_plugin(
1441 hooks: &[&str],
1442 capabilities: &[&str],
1443 ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1444 let dir = TempDir::new();
1445 let script = dir.path().join("adapter_stub.py");
1446 std::fs::write(&script, ADAPTER_STUB).unwrap();
1447
1448 let config = PluginConfig {
1449 name: "py-plugin".into(),
1450 kind: crate::factory::KIND.into(),
1451 hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
1452 capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(),
1453 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
1454 ..Default::default()
1455 };
1456
1457 let plugin = Arc::new(
1458 IsolatedPythonPlugin::from_config(&config)
1459 .expect("config parses")
1460 .with_worker_override(script),
1461 );
1462 (dir, plugin)
1463 }
1464
1465 fn last_task(dir: &TempDir) -> serde_json::Value {
1466 let raw = std::fs::read_to_string(dir.path().join("last_task.json"))
1467 .expect("the stub recorded a task");
1468 serde_json::from_str(&raw).expect("recorded task is JSON")
1469 }
1470
1471 async fn invoke(
1473 plugin: &Arc<IsolatedPythonPlugin>,
1474 hook: &'static str,
1475 payload: &dyn PluginPayload,
1476 ctx: &mut PluginContext,
1477 ) -> Result<cpex_core::executor::ErasedResultFields, Box<PluginError>> {
1478 let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook);
1479 let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?;
1480 Ok(cpex_core::executor::extract_erased(erased)
1481 .expect("the adapter returns ErasedResultFields"))
1482 }
1483
1484 #[tokio::test]
1485 async fn an_invoke_round_trips_and_returns_allow() {
1486 if crate::testing::skip_without_python3("an_invoke_round_trips_and_returns_allow") {
1487 return;
1488 }
1489 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1490 set_mode(&dir, "allow");
1491
1492 let payload = crate::legacy::ToolPreInvokePayload {
1493 name: "search".into(),
1494 args: Some(std::collections::HashMap::from([(
1495 "q".into(),
1496 serde_json::json!("rust"),
1497 )])),
1498 headers: None,
1499 };
1500 let mut ctx = PluginContext::new();
1501 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1502 .await
1503 .expect("the round trip succeeds");
1504
1505 assert!(fields.continue_processing);
1506 assert!(fields.violation.is_none());
1507
1508 let task = last_task(&dir);
1510 assert_eq!(task["task_type"], "load_and_run_hook");
1511 assert_eq!(task["hook_type"], "tool_pre_invoke");
1512 assert_eq!(task["plugin_name"], "py-plugin");
1513 assert_eq!(task["class_name"], "my_pkg.Plugin");
1514 assert_eq!(task["payload"]["name"], "search");
1515 assert_eq!(task["payload"]["args"]["q"], "rust");
1516
1517 let config_str = task["config"]
1519 .as_str()
1520 .expect("config must be a JSON string");
1521 let config: serde_json::Value = serde_json::from_str(config_str).unwrap();
1522 assert_eq!(config["name"], "py-plugin");
1523
1524 plugin.shutdown().await.expect("shuts down");
1525 }
1526
1527 #[tokio::test]
1528 async fn a_deny_response_becomes_a_deny_result_with_its_violation() {
1529 if crate::testing::skip_without_python3(
1530 "a_deny_response_becomes_a_deny_result_with_its_violation",
1531 ) {
1532 return;
1533 }
1534 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1535 set_mode(&dir, "deny");
1536
1537 let payload = crate::legacy::ToolPreInvokePayload {
1538 name: "search".into(),
1539 ..Default::default()
1540 };
1541 let mut ctx = PluginContext::new();
1542 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1543 .await
1544 .expect("a deny is a successful invocation that returns a deny");
1545
1546 assert!(!fields.continue_processing);
1547 let violation = fields.violation.expect("a deny must carry its violation");
1548 assert_eq!(violation.code, "PII_DETECTED");
1549 assert_eq!(violation.reason, "email in args");
1550 assert_eq!(violation.details["field"], "q");
1551
1552 plugin.shutdown().await.unwrap();
1553 }
1554
1555 #[tokio::test]
1556 async fn a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police() {
1557 if crate::testing::skip_without_python3(
1560 "a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police",
1561 ) {
1562 return;
1563 }
1564 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1565 set_mode(&dir, "error");
1566
1567 let payload = crate::legacy::ToolPreInvokePayload {
1568 name: "search".into(),
1569 ..Default::default()
1570 };
1571 let mut ctx = PluginContext::new();
1572 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
1573 panic!("a worker error response must not read as success");
1574 };
1575
1576 match *err {
1577 PluginError::Execution {
1578 ref plugin_name,
1579 ref code,
1580 ref message,
1581 ..
1582 } => {
1583 assert_eq!(plugin_name, "py-plugin");
1584 assert_eq!(code.as_deref(), Some("worker_error"));
1585 assert!(
1586 message.contains("ValueError"),
1587 "the plugin's message should survive: {message}"
1588 );
1589 },
1590 ref other => panic!("expected an Execution error, got {other:?}"),
1591 }
1592
1593 plugin.shutdown().await.unwrap();
1594 }
1595
1596 #[tokio::test]
1597 async fn context_deltas_are_written_back_into_the_plugin_context() {
1598 if crate::testing::skip_without_python3(
1599 "context_deltas_are_written_back_into_the_plugin_context",
1600 ) {
1601 return;
1602 }
1603 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1604 set_mode(&dir, "context");
1605
1606 let payload = crate::legacy::ToolPreInvokePayload {
1607 name: "search".into(),
1608 ..Default::default()
1609 };
1610 let mut ctx = PluginContext::new();
1611 ctx.set_local("preexisting", serde_json::json!("kept"));
1612
1613 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1614 .await
1615 .unwrap();
1616
1617 assert_eq!(ctx.get_local("seen"), Some(&serde_json::json!(1)));
1618 assert_eq!(ctx.get_global("shared"), Some(&serde_json::json!("yes")));
1619 assert_eq!(
1620 ctx.get_local("preexisting"),
1621 Some(&serde_json::json!("kept")),
1622 "deltas merge — a plugin touching one key must not blank the rest"
1623 );
1624
1625 plugin.shutdown().await.unwrap();
1626 }
1627
1628 #[tokio::test]
1629 async fn a_modified_payload_comes_back_as_the_hooks_typed_payload() {
1630 if crate::testing::skip_without_python3(
1631 "a_modified_payload_comes_back_as_the_hooks_typed_payload",
1632 ) {
1633 return;
1634 }
1635 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1636 set_mode(&dir, "modify");
1637
1638 let payload = crate::legacy::ToolPreInvokePayload {
1639 name: "search".into(),
1640 ..Default::default()
1641 };
1642 let mut ctx = PluginContext::new();
1643 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1644 .await
1645 .unwrap();
1646
1647 let modified = fields.modified_payload.expect("the modification survives");
1648 let typed = modified
1649 .as_any()
1650 .downcast_ref::<crate::legacy::ToolPreInvokePayload>()
1651 .expect("must come back as the tool payload or the executor's downcast fails");
1652 assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
1653
1654 plugin.shutdown().await.unwrap();
1655 }
1656
1657 #[tokio::test]
1658 async fn a_cmf_hook_sends_the_message_payload_shape() {
1659 if crate::testing::skip_without_python3("a_cmf_hook_sends_the_message_payload_shape") {
1660 return;
1661 }
1662 let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
1663 set_mode(&dir, "allow");
1664
1665 let payload = cpex_core::cmf::MessagePayload {
1666 message: cpex_core::cmf::Message::text(cpex_core::cmf::Role::User, "hello"),
1667 };
1668 let mut ctx = PluginContext::new();
1669 invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
1670 .await
1671 .unwrap();
1672
1673 let task = last_task(&dir);
1674 assert_eq!(task["hook_type"], "cmf.tool_pre_invoke");
1675 assert_eq!(
1676 task["payload"]["message"]["role"], "user",
1677 "a cmf hook must send the CMF message payload, not the generic wrapper"
1678 );
1679
1680 plugin.shutdown().await.unwrap();
1681 }
1682
1683 #[tokio::test]
1684 async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() {
1685 let config = PluginConfig {
1686 name: "uninitialized".into(),
1687 kind: crate::factory::KIND.into(),
1688 hooks: vec!["tool_pre_invoke".into()],
1689 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
1690 ..Default::default()
1691 };
1692 let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
1693
1694 let payload = crate::legacy::ToolPreInvokePayload {
1695 name: "search".into(),
1696 ..Default::default()
1697 };
1698 let mut ctx = PluginContext::new();
1699 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
1700 panic!("dispatch without a worker cannot succeed");
1701 };
1702 assert!(matches!(*err, PluginError::Execution { .. }));
1703 }
1704
1705 #[tokio::test]
1706 async fn a_credential_capability_attaches_the_dto_on_an_identity_hook() {
1707 if crate::testing::skip_without_python3(
1710 "a_credential_capability_attaches_the_dto_on_an_identity_hook",
1711 ) {
1712 return;
1713 }
1714 use cpex_core::extensions::raw_credentials::{
1715 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1716 };
1717
1718 let (dir, plugin) =
1719 adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
1720 set_mode(&dir, "allow");
1721
1722 let mut raw = RawCredentialsExtension::default();
1723 raw.inbound_tokens.insert(
1724 TokenRole::User,
1725 RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
1726 );
1727 let extensions = Extensions {
1728 raw_credentials: Some(Arc::new(raw)),
1729 ..Default::default()
1730 };
1731
1732 let payload = crate::legacy::IdentityResolvePayload::default();
1733 let mut ctx = PluginContext::new();
1734 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
1735 adapter
1736 .invoke(&payload, &extensions, &mut ctx)
1737 .await
1738 .expect("dispatch succeeds");
1739
1740 let task = last_task(&dir);
1741 assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN");
1742 assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
1743
1744 plugin.shutdown().await.unwrap();
1745 }
1746
1747 #[tokio::test]
1748 async fn no_capability_means_no_credential_reaches_the_worker() {
1749 if crate::testing::skip_without_python3(
1750 "no_capability_means_no_credential_reaches_the_worker",
1751 ) {
1752 return;
1753 }
1754 use cpex_core::extensions::raw_credentials::{
1755 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1756 };
1757
1758 let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
1760 set_mode(&dir, "allow");
1761
1762 let mut raw = RawCredentialsExtension::default();
1763 raw.inbound_tokens.insert(
1764 TokenRole::User,
1765 RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
1766 );
1767 let extensions = Extensions {
1768 raw_credentials: Some(Arc::new(raw)),
1769 ..Default::default()
1770 };
1771
1772 let payload = crate::legacy::IdentityResolvePayload::default();
1773 let mut ctx = PluginContext::new();
1774 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
1775 adapter
1776 .invoke(&payload, &extensions, &mut ctx)
1777 .await
1778 .unwrap();
1779
1780 let task = last_task(&dir);
1781 assert!(task.get("credential").is_none(), "no credential field");
1782 assert!(
1783 !serde_json::to_string(&task)
1784 .unwrap()
1785 .contains("E2E-SECRET-TOKEN"),
1786 "no token bytes anywhere in the task the worker received"
1787 );
1788
1789 plugin.shutdown().await.unwrap();
1790 }
1791
1792 fn last_pid(dir: &TempDir) -> u32 {
1795 std::fs::read_to_string(dir.path().join("last_pid"))
1796 .expect("the stub recorded its pid")
1797 .trim()
1798 .parse()
1799 .expect("pid is numeric")
1800 }
1801
1802 async fn await_worker_death(plugin: &Arc<IsolatedPythonPlugin>) {
1808 for _ in 0..200 {
1809 let alive = plugin
1810 .worker
1811 .read()
1812 .await
1813 .as_ref()
1814 .is_some_and(|w| w.is_alive());
1815 if !alive {
1816 return;
1817 }
1818 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1819 }
1820 panic!("the worker never registered as dead");
1821 }
1822
1823 #[tokio::test]
1824 async fn a_dead_worker_is_respawned_on_the_next_invocation() {
1825 if crate::testing::skip_without_python3("a_dead_worker_is_respawned_on_the_next_invocation")
1830 {
1831 return;
1832 }
1833 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1834 set_mode(&dir, "allow");
1835
1836 let payload = crate::legacy::ToolPreInvokePayload {
1837 name: "search".into(),
1838 ..Default::default()
1839 };
1840 let mut ctx = PluginContext::new();
1841
1842 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1844 .await
1845 .expect("the first invocation succeeds");
1846 let original_pid = last_pid(&dir);
1847
1848 set_mode(&dir, "die");
1850 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
1851 await_worker_death(&plugin).await;
1852
1853 set_mode(&dir, "allow");
1856 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1857 .await
1858 .expect("a dead worker must be replaced, not left broken");
1859 assert!(fields.continue_processing);
1860
1861 let new_pid = last_pid(&dir);
1862 assert_ne!(
1863 original_pid, new_pid,
1864 "the request must be served by a respawned process"
1865 );
1866
1867 plugin.shutdown().await.unwrap();
1868 }
1869
1870 #[tokio::test]
1871 async fn a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup() {
1872 if crate::testing::skip_without_python3(
1878 "a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup",
1879 ) {
1880 return;
1881 }
1882 let (dir, plugin) =
1883 uninitialized_adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]);
1884 set_handshake_mode(&dir, "unsupported");
1886
1887 let err = plugin
1888 .initialize()
1889 .await
1890 .expect_err("a worker that would drop the credential must not be installed");
1891
1892 let message = err.to_string();
1893 assert!(
1894 message.contains("does not implement credential"),
1895 "the failure must name the missing feature: {message}"
1896 );
1897 assert!(
1898 message.contains("upgrade the cpex package"),
1899 "the failure must tell an operator what to do: {message}"
1900 );
1901
1902 assert!(
1905 plugin.worker.read().await.is_none(),
1906 "a failed handshake must not leave a worker in the slot"
1907 );
1908 }
1909
1910 #[tokio::test]
1911 async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() {
1912 if crate::testing::skip_without_python3(
1917 "a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker",
1918 ) {
1919 return;
1920 }
1921 let (dir, plugin) = uninitialized_adapter_plugin(&["tool_pre_invoke"], &[]);
1922 set_handshake_mode(&dir, "unsupported");
1923
1924 plugin
1925 .initialize()
1926 .await
1927 .expect("a plugin needing no gated field must still start on an old worker");
1928
1929 let payload = crate::legacy::ToolPreInvokePayload {
1931 name: "search".into(),
1932 ..Default::default()
1933 };
1934 let mut ctx = PluginContext::new();
1935 let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1936 .await
1937 .expect("the hook still dispatches");
1938 assert!(fields.continue_processing);
1939
1940 plugin.shutdown().await.unwrap();
1941 }
1942
1943 #[tokio::test]
1944 async fn a_worker_answering_the_handshake_with_no_features_also_fails_closed() {
1945 if crate::testing::skip_without_python3(
1950 "a_worker_answering_the_handshake_with_no_features_also_fails_closed",
1951 ) {
1952 return;
1953 }
1954 let (dir, plugin) =
1955 uninitialized_adapter_plugin(&["tool_pre_invoke"], &["read_labels", "append_labels"]);
1956 set_handshake_mode(&dir, "no_features");
1957
1958 let err = plugin
1959 .initialize()
1960 .await
1961 .expect_err("an extensions-declaring plugin needs a worker that delivers the field");
1962
1963 let message = err.to_string();
1964 assert!(
1965 message.contains("does not implement extensions"),
1966 "an extensions capability requires the extensions feature: {message}"
1967 );
1968 }
1969
1970 #[tokio::test]
1971 async fn a_respawned_worker_receives_the_same_capability_handshake() {
1972 if crate::testing::skip_without_python3(
1978 "a_respawned_worker_receives_the_same_capability_handshake",
1979 ) {
1980 return;
1981 }
1982 use cpex_core::extensions::raw_credentials::{
1983 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1984 };
1985
1986 let (dir, plugin) =
1987 adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
1988 set_mode(&dir, "allow");
1989
1990 let mut raw = RawCredentialsExtension::default();
1991 raw.inbound_tokens.insert(
1992 TokenRole::User,
1993 RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt),
1994 );
1995 let extensions = Extensions {
1996 raw_credentials: Some(Arc::new(raw)),
1997 ..Default::default()
1998 };
1999
2000 let payload = crate::legacy::IdentityResolvePayload::default();
2001 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
2002
2003 let mut ctx = PluginContext::new();
2004 adapter
2005 .invoke(&payload, &extensions, &mut ctx)
2006 .await
2007 .expect("the first dispatch succeeds");
2008 let original_pid = last_pid(&dir);
2009 assert_eq!(
2010 last_task(&dir)["credential"]["inbound"]["token"],
2011 "RESPAWN-SECRET"
2012 );
2013
2014 set_mode(&dir, "die");
2015 let mut ctx = PluginContext::new();
2016 let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2017 await_worker_death(&plugin).await;
2018
2019 set_mode(&dir, "allow");
2021 let mut ctx = PluginContext::new();
2022 adapter
2023 .invoke(&payload, &extensions, &mut ctx)
2024 .await
2025 .expect("dispatch succeeds against the respawned worker");
2026
2027 assert_ne!(original_pid, last_pid(&dir), "a new process served it");
2028 let task = last_task(&dir);
2029 assert_eq!(
2030 task["credential"]["inbound"]["token"], "RESPAWN-SECRET",
2031 "the respawned worker must get the same credential handshake"
2032 );
2033 assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
2034
2035 plugin.shutdown().await.unwrap();
2036 }
2037
2038 #[tokio::test]
2039 async fn a_respawned_worker_gets_no_credential_it_did_not_declare() {
2040 if crate::testing::skip_without_python3(
2043 "a_respawned_worker_gets_no_credential_it_did_not_declare",
2044 ) {
2045 return;
2046 }
2047 use cpex_core::extensions::raw_credentials::{
2048 RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2049 };
2050
2051 let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
2052 set_mode(&dir, "allow");
2053
2054 let mut raw = RawCredentialsExtension::default();
2055 raw.inbound_tokens.insert(
2056 TokenRole::User,
2057 RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt),
2058 );
2059 let extensions = Extensions {
2060 raw_credentials: Some(Arc::new(raw)),
2061 ..Default::default()
2062 };
2063
2064 let payload = crate::legacy::IdentityResolvePayload::default();
2065 let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
2066
2067 let mut ctx = PluginContext::new();
2068 adapter
2069 .invoke(&payload, &extensions, &mut ctx)
2070 .await
2071 .unwrap();
2072
2073 set_mode(&dir, "die");
2074 let mut ctx = PluginContext::new();
2075 let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2076 await_worker_death(&plugin).await;
2077
2078 set_mode(&dir, "allow");
2079 let mut ctx = PluginContext::new();
2080 adapter
2081 .invoke(&payload, &extensions, &mut ctx)
2082 .await
2083 .expect("dispatch succeeds after respawn");
2084
2085 let task = last_task(&dir);
2086 assert!(
2087 task.get("credential").is_none(),
2088 "respawn must not widen the capability gate"
2089 );
2090 assert!(
2091 !serde_json::to_string(&task)
2092 .unwrap()
2093 .contains("MUST-NOT-LEAK"),
2094 "no token bytes may reach a worker that declared nothing"
2095 );
2096
2097 plugin.shutdown().await.unwrap();
2098 }
2099
2100 #[tokio::test]
2101 async fn a_worker_that_will_not_stay_up_gives_up_with_a_typed_error() {
2102 if crate::testing::skip_without_python3(
2107 "a_worker_that_will_not_stay_up_gives_up_with_a_typed_error",
2108 ) {
2109 return;
2110 }
2111 let dir = TempDir::new();
2112 let script = dir.path().join("always_dies.py");
2113 std::fs::write(
2115 &script,
2116 "import sys\nprint('fatal: dependency missing', file=sys.stderr, flush=True)\nsys.exit(1)\n",
2117 )
2118 .unwrap();
2119
2120 let config = PluginConfig {
2121 name: "crash-loop".into(),
2122 kind: crate::factory::KIND.into(),
2123 hooks: vec!["tool_pre_invoke".into()],
2124 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2125 ..Default::default()
2126 };
2127 let plugin = Arc::new(
2128 IsolatedPythonPlugin::from_config(&config)
2129 .expect("config parses")
2130 .with_worker_override(script),
2131 );
2132 plugin.initialize().await.expect("the process launches");
2135
2136 let payload = crate::legacy::ToolPreInvokePayload {
2137 name: "search".into(),
2138 ..Default::default()
2139 };
2140
2141 let started = std::time::Instant::now();
2144 let mut gave_up = None;
2145 for _ in 0..(MAX_RESPAWN_ATTEMPTS + 4) {
2146 let mut ctx = PluginContext::new();
2147 let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
2148 panic!("a worker that always dies cannot serve a request");
2149 };
2150 let message = err.to_string();
2151 if message.contains("could not be respawned") {
2152 gave_up = Some(message);
2153 break;
2154 }
2155 }
2156
2157 let message = gave_up
2158 .expect("a permanently failing worker must give up with an error, not retry forever");
2159 assert!(
2160 message.contains(&MAX_RESPAWN_ATTEMPTS.to_string()),
2161 "the error should name the attempt bound: {message}"
2162 );
2163
2164 assert!(
2166 started.elapsed() < std::time::Duration::from_secs(20),
2167 "giving up took too long: {:?}",
2168 started.elapsed()
2169 );
2170
2171 plugin.shutdown().await.unwrap();
2172 }
2173
2174 #[tokio::test]
2175 async fn respawn_is_not_attempted_before_initialize() {
2176 let config = PluginConfig {
2179 name: "uninitialized".into(),
2180 kind: crate::factory::KIND.into(),
2181 hooks: vec!["tool_pre_invoke".into()],
2182 config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2183 ..Default::default()
2184 };
2185 let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
2186
2187 let Err(err) = plugin.worker().await else {
2188 panic!("a plugin with no worker must not silently spawn one");
2189 };
2190 let message = err.to_string();
2191 assert!(
2192 message.contains("initialize()"),
2193 "the error should name the lifecycle gap: {message}"
2194 );
2195 assert!(
2196 plugin.worker.read().await.is_none(),
2197 "no worker was started"
2198 );
2199 }
2200
2201 #[tokio::test]
2202 async fn concurrent_invocations_share_one_respawn() {
2203 if crate::testing::skip_without_python3("concurrent_invocations_share_one_respawn") {
2207 return;
2208 }
2209 let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2210 set_mode(&dir, "allow");
2211
2212 let payload = crate::legacy::ToolPreInvokePayload {
2213 name: "search".into(),
2214 ..Default::default()
2215 };
2216 let mut ctx = PluginContext::new();
2217 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2218 .await
2219 .unwrap();
2220
2221 set_mode(&dir, "die");
2222 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2223 await_worker_death(&plugin).await;
2224 set_mode(&dir, "allow");
2225
2226 let mut handles = Vec::new();
2228 for _ in 0..4 {
2229 let plugin = Arc::clone(&plugin);
2230 handles.push(tokio::spawn(async move {
2231 let payload = crate::legacy::ToolPreInvokePayload {
2232 name: "search".into(),
2233 ..Default::default()
2234 };
2235 let mut ctx = PluginContext::new();
2236 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2237 .await
2238 .map(|f| f.continue_processing)
2239 }));
2240 }
2241
2242 for handle in handles {
2243 assert!(handle.await.unwrap().expect("each invocation recovers"));
2244 }
2245
2246 set_mode(&dir, "die");
2249 let mut ctx = PluginContext::new();
2250 let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2251 await_worker_death(&plugin).await;
2252 set_mode(&dir, "allow");
2253
2254 let mut ctx = PluginContext::new();
2255 invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2256 .await
2257 .expect("budget was not exhausted by the concurrent respawn");
2258
2259 plugin.shutdown().await.unwrap();
2260 }
2261
2262 #[test]
2263 fn the_respawn_budget_is_bounded_then_resets_with_time() {
2264 let mut state = RespawnState::new();
2267 for expected in 1..=MAX_RESPAWN_ATTEMPTS {
2268 assert_eq!(state.try_reserve(), Some(expected));
2269 }
2270 assert_eq!(
2271 state.try_reserve(),
2272 None,
2273 "the budget must be bounded, not unlimited"
2274 );
2275
2276 state.record_success();
2278 assert_eq!(state.try_reserve(), Some(1));
2279
2280 let mut state = RespawnState::new();
2283 while state.try_reserve().is_some() {}
2284 state.window_started = Some(
2285 std::time::Instant::now()
2286 - std::time::Duration::from_secs(RESPAWN_BUDGET_RESET_SECS + 1),
2287 );
2288 assert_eq!(
2289 state.try_reserve(),
2290 Some(1),
2291 "an elapsed window must restore the budget"
2292 );
2293 }
2294
2295 #[test]
2296 fn unknown_config_keys_are_tolerated() {
2297 let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
2301 "class_name": "my_pkg.Minimal",
2302 "some_python_only_setting": true,
2303 })))
2304 .expect("extra keys belong to the Python plugin, not this host");
2305 assert_eq!(cfg.class_name, "my_pkg.Minimal");
2306 }
2307}