Skip to main content

cpex_hosts_python/
plugin.rs

1// Location: ./crates/cpex-hosts-python/src/plugin.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// `IsolatedPythonPlugin` — the plugin object the factory produces, and
7// `PythonHookAdapter` — the per-hook handler that drives one invocation.
8//
9// The plugin owns the venv manager and the worker client and implements the
10// lifecycle half of the contract (initialize / shutdown). The adapter
11// implements `AnyHookHandler` directly rather than going through
12// `TypedHandlerAdapter`: that adapter downcasts to one fixed payload type,
13// but this host forwards whatever payload arrives, serialized, to a worker
14// that reconstructs it on the Python side. The concrete type is not known at
15// Rust compile time, so there is nothing to downcast to.
16
17use 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
37/// Default cap on the serialized size of one outbound task, in bytes.
38///
39/// Mirrors `client.py`'s `max_content_size` default. The bound exists so a
40/// pathological payload fails fast on this side rather than after the worker
41/// has already buffered it.
42const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
43
44/// Default per-invocation timeout, in seconds.
45///
46/// Mirrors `venv_comm.py`'s `send_task` default. A plugin entry may override
47/// it; absent both, the executor's global `plugin_settings.plugin_timeout`
48/// still bounds the call from outside.
49const DEFAULT_TIMEOUT_SECS: u64 = 30;
50
51/// Path to the worker script, relative to the venv's installed `cpex`.
52///
53/// The worker ships inside the venv via the plugin's `cpex` dependency, so
54/// the host does not carry a copy of it.
55const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
56
57/// How many times a dead worker may be respawned before the plugin gives up.
58///
59/// Bounded rather than unlimited: a worker that dies on every launch is a
60/// broken plugin (a bad dependency pin, a syntax error, an import that raises),
61/// and retrying it forever converts a fast, legible failure into an
62/// indefinitely wedged hook that hammers the machine. Three attempts covers the
63/// case respawn exists for — a worker killed by an OOM reaper, a crash inside
64/// one plugin invocation — without papering over a plugin that cannot start.
65const MAX_RESPAWN_ATTEMPTS: u32 = 3;
66
67/// Base delay before the first respawn attempt.
68///
69/// Doubled per attempt (100ms, 200ms, 400ms). The backoff exists so a worker
70/// that dies immediately on launch cannot spin the host in a tight
71/// spawn/crash loop; the values stay small because a live request is waiting
72/// behind them and the executor's own timeout is still running.
73const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
74
75/// Interval after which a plugin that has exhausted its respawn budget is
76/// allowed to try again.
77///
78/// Without this, one bad patch of the worker's environment would permanently
79/// poison the plugin for the process's lifetime, even after the underlying
80/// cause is fixed. With it, a plugin that has been failing settles into one
81/// retry per minute rather than either hammering or staying dead forever.
82const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
83
84/// Directory, relative to the project root, holding installed Python plugins.
85///
86/// This is the sole source of the worker's `sys.path` entries and of the
87/// directory the venv is built under. It is *not* configurable per plugin: a
88/// `plugin_dirs:` key inside a plugin's `config:` block is ignored (see
89/// `VenvConfig`). A top-level `plugin_dirs:` YAML key is rejected by the
90/// config loader.
91///
92/// # Why a fixed default rather than a config key
93///
94/// `cpex plugin install` writes the plugin into `<project root>/plugins/` and
95/// builds its venv there. Both the Python CLI and this host therefore already
96/// agree on the location; making it configurable only creates a way for the
97/// two to disagree, and the failure mode is an import error inside the worker
98/// at invoke time, far from the config that caused it.
99pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
100
101/// The project root the `plugins` directory is resolved against.
102///
103/// The process working directory. That is not an arbitrary choice: the
104/// worker's own `ALLOWED_PLUGIN_DIRS` allowlist accepts `os.getcwd()`, and the
105/// worker inherits this host's cwd, so a directory resolved this way is
106/// importable by construction. Anchoring anywhere else would produce paths the
107/// worker rejects.
108fn project_root() -> PathBuf {
109    // A cwd that cannot be read is not recoverable here, and returning a
110    // relative path keeps the failure legible: the venv layout error names
111    // `plugins`, which is what an operator would look for.
112    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
113}
114
115/// The plugin directory list the worker and venv layout both use.
116///
117/// One entry: `<project root>/plugins`. Absolute, so the value does not shift
118/// if something later changes the process cwd between `initialize()` and an
119/// invocation.
120fn default_plugin_dirs() -> Vec<String> {
121    vec![
122        project_root()
123            .join(DEFAULT_PLUGIN_DIR)
124            .display()
125            .to_string(),
126    ]
127}
128
129/// Global-state key a host can set to propagate its own request id into the
130/// worker's `GlobalContext.request_id`.
131///
132/// The Rust `PluginContext` has no dedicated request-id field, but the Python
133/// `GlobalContext` requires one. Reading it from global state lets a gateway
134/// that tracks request ids keep them consistent across the boundary.
135pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
136
137/// Capability names the Python `Capability` enum models
138/// (`cpex/framework/extensions/tiers.py`).
139///
140/// This is deliberately a mirror of the *other* side's vocabulary rather than
141/// this host's own. `PluginConfig`'s `capabilities` validator raises on any name
142/// it does not recognize, and that happens during config construction inside the
143/// worker, so an unrecognized name is a dead hook rather than a narrower view.
144/// See [`IsolatedPythonPlugin::worker_capabilities`].
145///
146/// The host's vocabulary is the superset: `read_all`, `read_client`,
147/// `read_workload`, `read_inbound_credentials`, and `read_delegated_tokens` have
148/// no Python counterpart yet and are intentionally absent here. When the Python
149/// enum grows one, adding it here is what lets it cross the boundary.
150const 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/// Venv-relevant settings, parsed from a plugin entry's opaque `config` map.
166///
167/// `PluginConfig` has no fields for a class name, a content-size cap, or a
168/// per-plugin timeout — those are host-specific, so they live in the
169/// free-form `config` value and are parsed here.
170///
171/// # `plugin_dirs` is deliberately absent
172///
173/// A `plugin_dirs:` key in this block is **ignored**; a top-level
174/// `plugin_dirs:` YAML key fails the config load. Plugin directories are not configurable: the host
175/// always uses `<project root>/plugins`, which is where `cpex plugin install`
176/// puts them. See `DEFAULT_PLUGIN_DIR`. Serde ignores unknown fields, so an
177/// existing config that still carries the key parses without error — the value
178/// simply has no effect.
179#[derive(Debug, Clone, Deserialize)]
180pub struct VenvConfig {
181    /// Fully-qualified Python class implementing the plugin, e.g.
182    /// `my_pkg.filters.PiiFilter`. Required: the worker needs it to import
183    /// and instantiate the plugin, and the venv cache is keyed on it.
184    pub class_name: String,
185
186    /// Requirements file to install into the venv, relative to the package
187    /// root. Optional — a plugin installed by FQN conversion gets its
188    /// dependencies from the install channel, not a requirements file, and
189    /// then the manifest version plus hash is the sole cache signal.
190    #[serde(default)]
191    pub requirements_file: Option<String>,
192
193    /// Worker script path inside the venv. Defaults to the framework's
194    /// `worker.py`; overridable for tests and for a host that vendors its own.
195    #[serde(default = "default_script_path")]
196    pub script_path: String,
197
198    /// Cap on one serialized outbound task, in bytes.
199    #[serde(default = "default_max_content_size")]
200    pub max_content_size: usize,
201
202    /// Per-invocation timeout, in seconds. Falls back to the framework
203    /// default when absent.
204    #[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    /// Parse the venv settings out of a plugin entry's `config` map.
222    ///
223    /// A missing or malformed `class_name` is a config error, not a panic —
224    /// the engine surfaces it against the plugin name at load time.
225    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
245/// A Python plugin running out-of-process in its own virtualenv.
246pub struct IsolatedPythonPlugin {
247    /// The authoritative config entry, kept for `Plugin::config()`.
248    config: PluginConfig,
249
250    /// Venv settings parsed from the entry's `config` map.
251    venv_config: VenvConfig,
252
253    /// Directories the worker prepends to `sys.path`, and under whose first
254    /// entry the venv is built.
255    ///
256    /// Host-owned rather than config-derived: `<project root>/plugins`, per
257    /// `DEFAULT_PLUGIN_DIR`. Held here rather than recomputed per invocation so
258    /// the value stays fixed for the plugin's lifetime even if the process cwd
259    /// changes after construction.
260    plugin_dirs: Vec<String>,
261
262    /// Builds and caches the venv. `None` only when the layout cannot be
263    /// resolved (an empty dir list, or a `class_name` with no package
264    /// segment), which is valid solely for a `worker_override` test setup.
265    venv: Option<VenvManager>,
266
267    /// The live worker, populated by `initialize()`.
268    worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
269
270    /// Absolute worker-script path that bypasses venv resolution. Tests point
271    /// this at a stub; production leaves it unset and resolves from the venv.
272    worker_override: Option<PathBuf>,
273
274    /// Working directory for the worker process.
275    ///
276    /// Defaults to the host's own cwd, matching `venv_comm.py`'s
277    /// `cwd=os.getcwd()`. This is load-bearing twice over: the worker's
278    /// `ALLOWED_PLUGIN_DIRS` allowlist accepts `os.getcwd()`, so a plugin dir
279    /// outside it is rejected outright; and a plugin's relative-path side
280    /// effects land here.
281    worker_cwd: Option<PathBuf>,
282
283    /// Respawn bookkeeping for a worker that dies after a successful
284    /// `initialize()`.
285    respawn: tokio::sync::Mutex<RespawnState>,
286
287    /// Set by `shutdown()` before it empties the worker slot, and cleared by
288    /// `initialize()`. A respawn already under way when the plugin shuts down
289    /// checks it, so it cannot launch and install a worker nothing will stop.
290    shut_down: std::sync::atomic::AtomicBool,
291}
292
293/// How many respawns this plugin has spent, and when the budget last reset.
294///
295/// Held behind its own mutex rather than folded into the worker lock so the
296/// accounting survives the worker slot being replaced, and so two concurrent
297/// invocations that both notice the same death cooperate: the first through
298/// respawns, the second finds a live worker and spends nothing.
299#[derive(Debug)]
300struct RespawnState {
301    /// Respawn attempts spent since the last budget reset.
302    attempts: u32,
303    /// When the budget was last reset, for the [`RESPAWN_BUDGET_RESET_SECS`]
304    /// window. `None` until the first respawn.
305    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    /// Reserve one respawn attempt, or report the budget is spent.
317    ///
318    /// Resets the counter first when the window has elapsed, so a plugin that
319    /// failed hard an hour ago is not still being punished for it.
320    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    /// Return the budget to full after a respawn that produced a live worker.
341    fn record_success(&mut self) {
342        self.attempts = 0;
343        self.window_started = None;
344    }
345}
346
347impl IsolatedPythonPlugin {
348    /// Build a plugin from its config entry, parsing the venv settings.
349    pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
350        let venv_config = VenvConfig::from_plugin_config(config)?;
351
352        // Not read from the config block — see `DEFAULT_PLUGIN_DIR`. A
353        // `plugin_dirs:` key there (or at the YAML top level) is ignored;
354        // warning about one is the factory's job, since it can name the plugin.
355        let plugin_dirs = default_plugin_dirs();
356
357        // A layout that will not resolve leaves the venv absent. That is a
358        // config error in production, but the failure belongs in
359        // `initialize()` rather than here: the factory should still hand back a
360        // named plugin the engine can report against.
361        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    /// The resolved plugin directories — `<project root>/plugins`.
393    pub fn plugin_dirs(&self) -> &[String] {
394        &self.plugin_dirs
395    }
396
397    /// Run the worker with an explicit working directory.
398    ///
399    /// Production leaves this unset and inherits the host's cwd. Tests set it
400    /// so their scratch plugin dir falls inside the worker's
401    /// `ALLOWED_PLUGIN_DIRS` (which accepts `os.getcwd()`), and so a plugin's
402    /// marker files land somewhere the test can find and clean up.
403    #[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    /// Point the plugin at explicit plugin directories, replacing the
410    /// `<project root>/plugins` default.
411    ///
412    /// Test-only seam. Production has no override by design — the directory is
413    /// fixed so the host and `cpex plugin install` cannot disagree. The e2e
414    /// tests scaffold a plugin into a temp dir and need the venv built there
415    /// rather than in the developer's real `plugins/`, which a test must not
416    /// touch.
417    ///
418    /// Rebuilds the venv manager, since the layout is derived from these dirs.
419    #[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    /// Point the plugin at an explicit worker script, skipping venv build and
433    /// script resolution.
434    ///
435    /// Test-only seam: the adapter tests need to exercise dispatch against a
436    /// stub worker without paying for a real venv, and the credential tests
437    /// need a worker that reports exactly what it received.
438    #[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    /// The parsed venv settings.
445    pub fn venv_config(&self) -> &VenvConfig {
446        &self.venv_config
447    }
448
449    /// The live worker's retained stderr, for leak assertions in tests.
450    ///
451    /// Returns an empty vector when no worker is running.
452    #[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    /// Launch a worker process for this plugin.
461    ///
462    /// The single path both `initialize()` and the respawn take, which is what
463    /// makes a respawned worker indistinguishable from the original: same
464    /// interpreter, same script, same cwd, same content cap, same timeout. The
465    /// security posture is preserved by construction rather than by a second
466    /// copy of the setup that could drift — the capability and credential
467    /// handshake is applied per invocation in `PythonHookAdapter::invoke`
468    /// against `self.config.capabilities`, so a fresh worker is gated by exactly
469    /// the same declared capabilities as the one it replaced. There is no
470    /// "degraded" or "retry" mode that relaxes it.
471    async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
472        // With an override the worker script is given outright, so neither a
473        // venv nor script resolution is needed — but the interpreter still is.
474        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        // Matching `venv_comm.py`'s `cwd=os.getcwd()`. The worker's
497        // ALLOWED_PLUGIN_DIRS allowlist accepts its own cwd, so inheriting the
498        // host's is what lets a plugin dir declared relative to the gateway be
499        // importable at all.
500        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    /// The live worker, respawning it when the previous one has died.
511    ///
512    /// A worker can die between invocations — an OOM kill, a segfault in a
513    /// native dependency, a plugin that calls `sys.exit`. Before this, the
514    /// plugin stayed permanently broken: every later invocation found the dead
515    /// `WorkerClient` still in the slot and failed against it, so one crash
516    /// disabled the plugin for the lifetime of the process.
517    ///
518    /// Respawn is bounded ([`MAX_RESPAWN_ATTEMPTS`]) and backed off, and a
519    /// plugin that exhausts its budget returns a typed `WorkerStart` error
520    /// rather than retrying forever.
521    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        // The common path: a worker exists and is alive. No lock beyond the
525        // read, so a healthy invocation pays nothing for the respawn machinery.
526        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            // Never initialized is not a respawn case — respawning here would
534            // paper over a lifecycle bug (or an initialize() that failed and
535            // was rolled back) by silently starting a worker the engine does
536            // not know about.
537            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    /// Replace a dead worker, under the respawn lock.
546    ///
547    /// Split out so the happy path in `worker()` stays lock-free. Holding the
548    /// respawn mutex across the relaunch serializes concurrent invocations that
549    /// all noticed the same death: the first respawns, the rest re-check and
550    /// find a live worker without spending budget.
551    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        // Re-check under the lock. A concurrent invocation may already have
556        // done the work while this one waited, or the plugin may have shut
557        // down. An empty slot means the latter: a failed respawn leaves the
558        // dead worker in place, so only `shutdown()` empties it.
559        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        // Exponential: 100ms, 200ms, 400ms. Applied *before* the spawn so a
583        // worker that dies instantly on launch cannot spin a tight loop.
584        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        // The plugin may have shut down during the backoff.
597        if self.is_shut_down() {
598            return Err(to_plugin_error(Self::shut_down_error()));
599        }
600
601        // Reap the corpse before replacing it, so the old process and its I/O
602        // tasks are not left behind on every respawn. It stays in the slot
603        // until a replacement is installed: if the launch below fails, the
604        // next invocation finds a dead worker and comes back here, within the
605        // same budget, instead of reading an empty slot as "never initialized"
606        // and giving up on the plugin for good.
607        if let Err(e) = dead.shutdown().await {
608            tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
609        }
610
611        // Same launch path as `initialize()` — see `launch_worker`.
612        let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
613
614        // Re-verified rather than assumed from initialize(): the replacement is a
615        // fresh process resolving `worker.py` from the venv again, and the venv
616        // can have been rebuilt or downgraded since. Skipping this would make
617        // respawn the one path that installs an unverified worker.
618        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        // Checked under the slot's write lock, which `shutdown()` takes after
624        // setting the flag: either this sees the flag, or `shutdown()` waits
625        // here and then stops the worker installed below.
626        {
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        // Deliberately *not* `record_success()` here.
637        //
638        // `is_alive()` reflects the reader task's last observation, and a worker
639        // that dies at import time has not necessarily been observed yet — it
640        // reports alive for the moment right after spawn. Resetting the budget
641        // on that would let a crash-looping worker refill its allowance on every
642        // attempt and retry forever, which is exactly what the bound exists to
643        // prevent. The budget is instead reset by the passage of time
644        // (`RESPAWN_BUDGET_RESET_SECS`), so recovery is still possible once the
645        // underlying cause is fixed, and by a *successful invocation* through
646        // `note_worker_healthy`, which is proof the worker answered rather than
647        // merely proof it launched.
648        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    /// Mark the worker healthy after it has actually answered a request.
658    ///
659    /// Called from the adapter on a successful round trip. A completed
660    /// invocation is the only evidence that distinguishes a worker that started
661    /// from a worker that works, so it — not a successful spawn — is what
662    /// returns the respawn budget to full.
663    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    /// The declared capabilities, reduced to the ones the worker's
671    /// `PluginConfig` model will accept.
672    ///
673    /// The Python model's `capabilities` validator *raises* on a name its
674    /// `Capability` enum does not know, and Pydantic validation happens while
675    /// the worker is constructing the config — before the plugin is ever
676    /// called. This host's capability vocabulary is the larger of the two
677    /// (`read_client`, `read_workload`, `read_all`, and the two
678    /// `raw_credentials` caps have no Python counterpart yet), so forwarding
679    /// the set verbatim would take down every hook of any plugin declaring one
680    /// of them.
681    ///
682    /// Dropping the unknown names is safe in the direction that matters:
683    /// the extensions the worker receives were already filtered by the
684    /// executor, so a dropped capability can only make the worker's
685    /// re-filter *more* restrictive, never less. A slot this host chose not to
686    /// send cannot be recovered by a capability string. The reverse — keeping an
687    /// unknown name — trades a possibly-narrower view for a dead worker.
688    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            // Worth a line: it means this host's vocabulary has grown past the
698            // installed framework's, and a plugin declaring one of these sees a
699            // narrower view out-of-process than it would in-process.
700            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    /// Fail closed unless the worker implements every wire feature this
712    /// plugin's declaration commits the host to using.
713    ///
714    /// # Why a version check would not do
715    ///
716    /// The host cannot tell an old `worker.py` from a current one by version:
717    /// two framework builds shipped as 0.1.2 with different worker protocols. An
718    /// old worker also does not *reject* a field it does not know — it ignores
719    /// it. So a plugin declaring `read_inbound_credentials` against such a
720    /// worker ran with no credential at all, silently, which is the exact case
721    /// fail-closed rule 2 exists to prevent. The worker is therefore asked
722    /// directly, and a worker that cannot answer is assumed to support nothing.
723    ///
724    /// Only features this plugin actually needs are required. A plugin that
725    /// declares no gated capability and runs no credential hook is served fine
726    /// by an old worker, and refusing to start it would be a regression for
727    /// every existing plugin.
728    async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
729        let required = self.required_worker_features();
730
731        // Tool, prompt and resource `cmf.*` hooks also need the report to
732        // choose native dispatch or the legacy shim. When they require no wire
733        // feature, an inconclusive probe leaves the report unset and the
734        // adapter falls back to the shim. LLM hooks require `cmf_hooks` above.
735        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        // Sorted so the diagnostic is stable across runs: `capabilities` is a
783        // HashSet, and an operator comparing two startup failures should not
784        // have to notice that only the ordering changed.
785        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    /// Say once, at startup, how this plugin's `cmf.*` hooks will be served.
820    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    /// The wire features this plugin's declaration commits the host to using.
834    ///
835    /// Derived from the declaration rather than from what a given request
836    /// happens to carry, so the check is decidable at startup and does not vary
837    /// per invocation.
838    fn required_worker_features(&self) -> Vec<&'static str> {
839        let mut required = Vec::new();
840
841        // LLM messages have no legacy worker payload. A worker without native
842        // CMF dispatch cannot serve these hooks even if it needs no capability.
843        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        // A credential hook only receives material when the matching capability
853        // is declared, so both halves are required for the field to travel —
854        // which makes the capability the precise trigger.
855        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        // Any capability the worker models is a capability whose slot this host
863        // sends in the `extensions` field. A worker that drops the field leaves
864        // the hook with `extensions=None`, so the plugin silently sees nothing
865        // of what it declared.
866        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    /// Build the `load_and_run_hook` task for one invocation.
879    ///
880    /// The field names and encodings are the protocol contract with
881    /// `worker.py`: `config` is a JSON *string* the worker `json.loads`es into
882    /// `PluginConfig(**config_raw)`, and `context` is the worker's own
883    /// `{state, global_context, metadata}` shape rather than the Rust
884    /// `PluginContext` layout.
885    ///
886    /// `legacy_cmf` selects the legacy payload shim's hook names for the
887    /// `cmf.*` hooks: true for a worker that does not claim `cmf_hooks`.
888    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        // The worker constructs a Python `PluginConfig` from this, so it must
896        // carry that model's field names — not this host's venv settings.
897        //
898        // `capabilities` has to travel even though this host already applies the
899        // executor's filtered view: the Python manager re-filters against
900        // `plugin_ref.capabilities` before it calls a 3-arg hook
901        // (`cpex/framework/manager.py`, `_execute_with_timeout`). Omitting the
902        // field left that set empty, so the second filter stripped every gated
903        // slot the first one had just allowed — a 3-arg plugin saw
904        // `security.labels` empty and `http` as `None` no matter what it
905        // declared. The two filters are now given the same input, so the second
906        // one is idempotent rather than destructive.
907        // Hook names travel in the spelling the worker registers, which differs
908        // from PPE's for the identity, delegation and CMF prompt hooks, and for
909        // every shimmed CMF hook.
910        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        // Prefer a request id the pipeline already put in global state, so a
929        // plugin's logs correlate with the gateway's. `PluginContext` has no
930        // dedicated field for one, so fall back to a fresh UUID rather than
931        // sending a placeholder that would collide across requests.
932        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                // `local_state` is this plugin's private slice; `global_state`
948                // is the pipeline-wide map the worker exposes as
949                // `global_context`.
950                "state": ctx.local_state,
951                "global_context": {
952                    // `request_id` is the one *required* field on the Python
953                    // `GlobalContext`; omitting it fails Pydantic validation
954                    // inside the worker before the plugin is ever called. The
955                    // Rust `PluginContext` carries no request id, so one is
956                    // synthesized per invocation — a plugin that correlates
957                    // logs by request id still gets a stable, unique value for
958                    // the call.
959                    "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    /// Build the venv (or reuse a cached one) and launch the worker.
975    ///
976    /// Heavy setup lives here rather than on the invoke path: the engine
977    /// awaits this once per plugin, with rollback on failure.
978    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        // Verify the worker understands what this plugin's declaration commits
987        // us to sending, before it is installed as the live worker. A mismatch
988        // is a startup failure, not a per-request one: the plugin cannot be
989        // served safely at all, and leaving it registered would mean discovering
990        // that on a live request instead.
991        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    /// Stop the worker, killing it if it will not exit on its own.
1000    async fn shutdown(&self) -> Result<(), Box<PluginError>> {
1001        // Before the slot is emptied, so a respawn in flight sees it. See
1002        // `respawn_worker`.
1003        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        // Give up this plugin's claim on the (possibly shared) venv, so a later
1015        // rebuild is not permanently downgraded to an in-place update by a stale
1016        // owner entry. Best-effort: a registry write failure must not turn a
1017        // clean shutdown into an error, since the venv itself is fine.
1018        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
1031/// Stop a worker that was launched but will not be installed.
1032async 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
1038/// The hook name the worker knows `hook_name` by, given how `cmf.*` hooks are
1039/// being served.
1040fn 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
1047/// Whether `worker` must be served `cmf.*` hooks through the legacy payload
1048/// shim: true unless its handshake claimed `cmf_hooks`. A worker that was never
1049/// asked, or could not answer, gets the shim.
1050fn 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
1056/// Resolve an absolute `python3` from PATH.
1057///
1058/// `WorkerClient::spawn` takes an interpreter path rather than doing a PATH
1059/// lookup, so the override path needs one. Production goes through the venv's
1060/// own interpreter and never calls this.
1061fn 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    // Fall back to asking the shell, which honors PATH (pyenv, conda, nix).
1072    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
1088/// Handler for one hook of one Python plugin.
1089///
1090/// Holds the hook name because a single plugin object serves every hook it
1091/// declared, and the worker needs to be told which one to run.
1092pub 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    /// Serialize, send, and convert one hook invocation.
1106    ///
1107    /// Every failure returns a `PluginError` and stops there — the *executor*
1108    /// applies the plugin's configured `on_error` policy (fail / ignore /
1109    /// disable). This host deliberately does not interpret that policy itself;
1110    /// doing so would double-apply it.
1111    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        // The worker first: whether it serves `cmf.*` hooks natively decides
1121        // what the task carries, and a respawn inside `worker()` may replace it
1122        // with one that answers differently.
1123        let worker = self.plugin.worker().await?;
1124
1125        // A probe that failed at startup leaves no report, which would pin
1126        // this worker to the shim for its whole life. Ask once more, the first
1127        // time a `cmf.*` hook needs the answer.
1128        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        // A worker that predates `cmf.*` hooks gets the legacy typed payload
1144        // under the legacy hook name. A message the legacy payload cannot
1145        // represent, and the LLM hooks, fail closed here.
1146        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        // Raw credentials, for the two hooks that carry them and only for a
1162        // plugin that declared the matching capability. Fails closed.
1163        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        // The capability-filtered extensions, so a 3-arg
1173        // `(payload, context, extensions)` hook sees out-of-process what it
1174        // would see in-process. Attaches nothing when no slot is visible.
1175        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        // A completed round trip is the only proof a (possibly respawned) worker
1180        // actually serves requests, so it is what returns the respawn budget.
1181        self.plugin.note_worker_healthy().await;
1182
1183        // State the worker sends back is merged into the caller's context
1184        // before the result is returned, so a plugin's context writes survive.
1185        apply_context_deltas(&response, ctx);
1186
1187        // Under the shim the modified payload is a legacy one, so it is taken
1188        // out before the generic conversion (which would parse it as a CMF
1189        // message) and mapped back onto the original message afterwards.
1190        // Allow, deny and the violation are converted unchanged.
1191        //
1192        // PPE's identity and delegation payloads take the same route: the
1193        // plugin answers with the worker's result model, which is mapped onto
1194        // a clone of the dispatched payload rather than parsed into a type
1195        // PPE's own downcasts would not find.
1196        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        // `extensions` is passed back in so the returned-extensions path can
1206        // reuse the inbound `Arc`s for immutable slots and read the write
1207        // tokens the executor issued. The executor's copy-on-write merge then
1208        // validates the result against the mutability tiers.
1209        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    /// The hook family the declared name belongs to, so the registry's family
1220    /// check accepts this handler. The adapter forwards whatever payload the
1221    /// hook carries, so it serves the family of any name it is declared on. A
1222    /// name with no family in the hook metadata reports itself, which the
1223    /// registry accepts.
1224    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
1231/// Map a modified payload the worker returned in its own model back onto the
1232/// payload PPE dispatched, for the shimmed `cmf.*` hooks and for PPE's
1233/// identity and delegation payloads.
1234fn 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    // A deny modifies nothing, so a result the plugin attached to one is not
1244    // worth failing the deny over.
1245    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                // A rejection is a deny, and a deny modifies nothing.
1261                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
1274/// Merge worker-returned context state back into the mutable plugin context.
1275///
1276/// The worker returns the context under the same `{state, global_context}`
1277/// shape it received. Merging rather than replacing is deliberate: a plugin
1278/// that touched one key must not blank out the rest of the pipeline's state.
1279fn 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        // The block above also carries `plugin_dirs`, which is no longer a
1335        // field — an unknown key must parse harmlessly rather than error, so
1336        // an existing config keeps loading.
1337    }
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        // These three defaults are the contract with `client.py` /
1347        // `venv_comm.py` — a drift here changes behavior silently.
1348        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    /// The plugin dir a config with no usable `plugin_dirs` anywhere resolves
1391    /// to: `<cwd>/plugins`.
1392    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        // Neither key is set anywhere, which is the shape the host now expects.
1403        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        // Absolute, so a later cwd change cannot move the venv out from under
1414        // a running plugin.
1415        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        // The key an existing config still carries. It must not steer the host,
1425        // and it must not fail the parse either — an operator upgrading should
1426        // see the warning (emitted by the factory) rather than a load error.
1427        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        // The Python framework keys payload models, hook methods and credential
1443        // handling on the older names, so the task's `hook_type` and the
1444        // config's `hooks` list both carry the worker's spelling.
1445        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                // No legacy name; the adapter refuses it before any task is built.
1518                "cmf.llm_input",
1519                "identity_resolve"
1520            ])
1521        );
1522    }
1523
1524    #[test]
1525    fn the_resolved_plugin_dir_is_what_the_worker_receives() {
1526        // The dir has to reach the worker's `sys.path`, not just the venv
1527        // layout — otherwise the venv is built in the right place and the
1528        // import still fails.
1529        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    /// Parse the `config` field, which travels as a JSON *string*.
1552    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    /// Build a task for a plugin declaring `capabilities`.
1558    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        // The Python manager re-filters extensions against the capabilities in
1576        // *its* config before calling a 3-arg hook. Send none and that filter
1577        // runs against an empty set, stripping every gated slot the executor
1578        // just allowed — which is how a plugin declaring `read_labels` ended up
1579        // seeing an empty label set out-of-process.
1580        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        // `PluginConfig`'s validator raises on an unknown capability, and it
1600        // does so while the worker builds the config — before the plugin runs.
1601        // So forwarding a host-only name costs every hook of that plugin, not
1602        // just the slot it gates. Dropping it can only narrow the view, which
1603        // the executor's own filter has already done.
1604        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        // The field is always present so the worker's config shape does not
1617        // depend on whether anything was declared; an empty list and an absent
1618        // field mean the same thing to Pydantic, and the explicit one is easier
1619        // to read off the wire when diagnosing a filtering question.
1620        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    // --- adapter dispatch ---------------------------------------------------
1630
1631    /// A stub worker that answers `load_and_run_hook` with a canned result and
1632    /// records the task it received.
1633    ///
1634    /// Behavior is driven by a marker file so a single script covers every
1635    /// case: the test writes `mode` next to the script before invoking.
1636    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    /// Build a plugin wired to the adapter stub, plus its scratch dir.
1776    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    /// How the stub should answer the spawn-time `capabilities` handshake.
1790    ///
1791    /// `"unsupported"` reproduces a worker predating the handshake;
1792    /// `"no_features"` a worker that knows the task but implements nothing.
1793    fn set_handshake_mode(dir: &TempDir, mode: &str) {
1794        std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
1795    }
1796
1797    /// Build a plugin wired to the adapter stub *without* initializing it.
1798    ///
1799    /// Splits the scratch dir from `initialize()` so a test can set the stub's
1800    /// handshake mode first and then assert on the startup result — which
1801    /// `adapter_plugin` cannot, since it unwraps.
1802    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    /// [`uninitialized_adapter_plugin`] with an explicit `config:` block.
1814    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    /// Invoke a hook through the adapter and return the erased result fields.
1847    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        // The task the worker received must carry the protocol contract.
1884        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        // `config` is a JSON *string* the worker json.loads()es — not an object.
1893        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        // The host returns the error and stops. Applying the on_error policy is
1933        // the executor's job — doing it here would double-apply it.
1934        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        // A worker claiming `cmf_hooks` gets the CMF message, not the shim.
2041        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    /// A CMF tool call after an empty text part, for the shim dispatch tests.
2067    /// The shim keeps the empty sibling part as it was.
2068    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        // The default stub handshake claims no `cmf_hooks`, like a 0.1.x worker.
2101        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        // Returns `{"name": "search", "args": {"q": "[REDACTED]"}}`.
2139        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        // Ties the capability gate to real dispatch: the DTO must reach the
2262        // worker's task, on the contract's field name.
2263        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        // Same hook, same request — this plugin simply declared nothing.
2370        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    // --- dead-worker respawn ------------------------------------------------
2404
2405    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    /// Wait until the client has observed the worker's death.
2414    ///
2415    /// `is_alive()` flips when the reader task sees EOF, which is asynchronous
2416    /// with respect to the process exiting. Polling for it keeps the respawn
2417    /// tests deterministic without a blind sleep.
2418    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        // Pre-fix, one crash disabled the plugin for the life of the process:
2437        // the dead WorkerClient stayed in the slot and every later invocation
2438        // failed against it. This asserts recovery, and asserts it came from a
2439        // genuinely new process rather than the old one somehow answering.
2440        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        // A healthy round trip establishes the original worker's pid.
2454        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        // Kill it: the stub exits without replying.
2460        set_mode(&dir, "die");
2461        let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2462        await_worker_death(&plugin).await;
2463
2464        // The next invocation must respawn and succeed rather than reporting a
2465        // permanently broken plugin.
2466        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        // Fail-closed rule 2. An older worker.py does not *reject* the credential
2484        // field, it ignores it — so before the handshake, a plugin declaring
2485        // read_inbound_credentials started clean and then ran with no credential
2486        // at all, silently. That is reachable in production because two framework
2487        // builds shipped as 0.1.2 with different worker protocols.
2488        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        // A worker predating the handshake: replies "task type not supported."
2496        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        // And the plugin must be left with no worker rather than a half-installed
2514        // one a later invocation could still reach.
2515        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        // The other half of the rule: the handshake must not become a blanket
2552        // version gate. A plugin that declares no gated capability is served
2553        // correctly by an old worker, and refusing to start it would break every
2554        // existing deployment for no safety gain.
2555        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        // ...and it must actually work, not merely start.
2569        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        // A worker that knows the task but reports an empty feature set is not
2585        // the same case as one that cannot answer, and must be treated the same:
2586        // what matters is whether the feature is claimed, never whether the
2587        // worker was new enough to be asked.
2588        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        // The security rail. A respawned worker must be gated by exactly the
2612        // capabilities the original was — never a weaker set, and never a
2613        // "recovery mode" that skips the gate. Both directions are asserted
2614        // against one respawn: the declared capability still delivers its
2615        // credential, and an undeclared slot still delivers nothing.
2616        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        // Same hook, same declared capability, new process.
2659        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        // The fail-closed half: a plugin with no declared capability must not
2680        // pick one up by virtue of having been respawned.
2681        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        // The anti-loop rail. A worker that dies on every launch must surface a
2742        // typed error after a bounded number of attempts rather than retrying
2743        // forever. Pointing the override at a script that always exits makes
2744        // every respawn produce a corpse.
2745        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        // Exits at import, before reading a task — so every spawn dies.
2753        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        // The process *starts* (then dies), so initialize succeeds — this is
2772        // exactly the shape a bad dependency pin produces.
2773        plugin.initialize().await.expect("the process launches");
2774
2775        let payload = crate::legacy::ToolPreInvokePayload {
2776            name: "search".into(),
2777            ..Default::default()
2778        };
2779
2780        // Drive invocations until one reports the budget is spent. The bound is
2781        // what is under test: without it this loop never terminates.
2782        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        // The backoff is bounded too — giving up must not take minutes.
2804        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        // Respawn must not paper over a lifecycle bug by starting a worker the
2816        // manager never asked for: an uninitialized plugin still errors.
2817        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        // Several hooks of one plugin can be in flight at once (the executor
2843        // dispatches parallel-phase plugins concurrently). They must not each
2844        // spend respawn budget on the same death and start competing workers.
2845        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        // Four concurrent invocations against one dead worker.
2866        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        // One respawn served all four, so the budget is intact — a second
2886        // independent death must still be recoverable.
2887        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    /// Lines in a file the stub appends to, or 0 when it has none.
2902    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        // A respawn whose replacement fails verification used to leave the
2911        // slot empty, which every later call read as "initialize() did not
2912        // run": one bad respawn disabled the plugin for good.
2913        if crate::testing::skip_without_python3(
2914            "a_failed_respawn_is_retried_on_the_next_invocation",
2915        ) {
2916            return;
2917        }
2918        // `read_labels` makes verification require the `extensions` feature.
2919        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        // The replacement launches but fails the handshake.
2936        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        // The cause is fixed; the next call respawns within the same budget.
2951        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        // A request that found the worker dead is in the respawn backoff when
2964        // the engine shuts the plugin down. The respawn must give up rather
2965        // than start a worker after shutdown, which nothing would stop.
2966        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        // Inside the first respawn's 100ms backoff.
2999        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        // Give a wrongly launched worker time to show up before asserting none did.
3008        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        // And later calls report the shutdown rather than respawning.
3017        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        // A small frame cap lets the stub fail a probe with an over-limit reply.
3031        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        // The startup probe fails; any later one claims `cmf_hooks`.
3037        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    // --- PPE identity and delegation payloads ---------------------------------
3103
3104    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        // The IdentityResult maps back onto the PPE payload PPE downcasts to.
3176        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        // PPE's bearer token is the caller's inbound credential.
3217        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        // Unit-level pin on the accounting, so the bound and the reset window
3250        // are covered without spawning processes.
3251        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        // A successful invocation refills it.
3262        state.record_success();
3263        assert_eq!(state.try_reserve(), Some(1));
3264
3265        // ...and so does the passage of the reset window, so a plugin is not
3266        // poisoned for the life of the process once its cause is fixed.
3267        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        // Plugin `config:` blocks are shared with the Python side, which reads
3283        // keys this host does not model. Rejecting them would break configs
3284        // that work under the Python CLI.
3285        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}