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 cpex_core::{
22    context::PluginContext,
23    error::PluginError,
24    extensions::Extensions,
25    hooks::payload::PluginPayload,
26    plugin::{Plugin, PluginConfig},
27    registry::AnyHookHandler,
28};
29use serde::Deserialize;
30
31use crate::error::HostError;
32use crate::venv::VenvManager;
33use crate::worker::WorkerClient;
34use crate::{conversion, credentials};
35
36/// Default cap on the serialized size of one outbound task, in bytes.
37///
38/// Mirrors `client.py`'s `max_content_size` default. The bound exists so a
39/// pathological payload fails fast on this side rather than after the worker
40/// has already buffered it.
41const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
42
43/// Default per-invocation timeout, in seconds.
44///
45/// Mirrors `venv_comm.py`'s `send_task` default. A plugin entry may override
46/// it; absent both, the executor's global `plugin_settings.plugin_timeout`
47/// still bounds the call from outside.
48const DEFAULT_TIMEOUT_SECS: u64 = 30;
49
50/// Path to the worker script, relative to the venv's installed `cpex`.
51///
52/// The worker ships inside the venv via the plugin's `cpex` dependency, so
53/// the host does not carry a copy of it.
54const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
55
56/// How many times a dead worker may be respawned before the plugin gives up.
57///
58/// Bounded rather than unlimited: a worker that dies on every launch is a
59/// broken plugin (a bad dependency pin, a syntax error, an import that raises),
60/// and retrying it forever converts a fast, legible failure into an
61/// indefinitely wedged hook that hammers the machine. Three attempts covers the
62/// case respawn exists for — a worker killed by an OOM reaper, a crash inside
63/// one plugin invocation — without papering over a plugin that cannot start.
64const MAX_RESPAWN_ATTEMPTS: u32 = 3;
65
66/// Base delay before the first respawn attempt.
67///
68/// Doubled per attempt (100ms, 200ms, 400ms). The backoff exists so a worker
69/// that dies immediately on launch cannot spin the host in a tight
70/// spawn/crash loop; the values stay small because a live request is waiting
71/// behind them and the executor's own timeout is still running.
72const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
73
74/// Interval after which a plugin that has exhausted its respawn budget is
75/// allowed to try again.
76///
77/// Without this, one bad patch of the worker's environment would permanently
78/// poison the plugin for the process's lifetime, even after the underlying
79/// cause is fixed. With it, a plugin that has been failing settles into one
80/// retry per minute rather than either hammering or staying dead forever.
81const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
82
83/// Directory, relative to the project root, holding installed Python plugins.
84///
85/// This is the sole source of the worker's `sys.path` entries and of the
86/// directory the venv is built under. It is *not* configurable per plugin: a
87/// `plugin_dirs:` key inside a plugin's `config:` block is ignored (see
88/// `VenvConfig`), as is the top-level `plugin_dirs:` YAML key, which
89/// `cpex_core` already parses and discards.
90///
91/// # Why a fixed default rather than a config key
92///
93/// `cpex plugin install` writes the plugin into `<project root>/plugins/` and
94/// builds its venv there. Both the Python CLI and this host therefore already
95/// agree on the location; making it configurable only creates a way for the
96/// two to disagree, and the failure mode is an import error inside the worker
97/// at invoke time, far from the config that caused it.
98pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
99
100/// The project root the `plugins` directory is resolved against.
101///
102/// The process working directory. That is not an arbitrary choice: the
103/// worker's own `ALLOWED_PLUGIN_DIRS` allowlist accepts `os.getcwd()`, and the
104/// worker inherits this host's cwd, so a directory resolved this way is
105/// importable by construction. Anchoring anywhere else would produce paths the
106/// worker rejects.
107fn project_root() -> PathBuf {
108    // A cwd that cannot be read is not recoverable here, and returning a
109    // relative path keeps the failure legible: the venv layout error names
110    // `plugins`, which is what an operator would look for.
111    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
112}
113
114/// The plugin directory list the worker and venv layout both use.
115///
116/// One entry: `<project root>/plugins`. Absolute, so the value does not shift
117/// if something later changes the process cwd between `initialize()` and an
118/// invocation.
119fn default_plugin_dirs() -> Vec<String> {
120    vec![project_root()
121        .join(DEFAULT_PLUGIN_DIR)
122        .display()
123        .to_string()]
124}
125
126/// Global-state key a host can set to propagate its own request id into the
127/// worker's `GlobalContext.request_id`.
128///
129/// The Rust `PluginContext` has no dedicated request-id field, but the Python
130/// `GlobalContext` requires one. Reading it from global state lets a gateway
131/// that tracks request ids keep them consistent across the boundary.
132pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
133
134/// Capability names the Python `Capability` enum models
135/// (`cpex/framework/extensions/tiers.py`).
136///
137/// This is deliberately a mirror of the *other* side's vocabulary rather than
138/// this host's own. `PluginConfig`'s `capabilities` validator raises on any name
139/// it does not recognize, and that happens during config construction inside the
140/// worker, so an unrecognized name is a dead hook rather than a narrower view.
141/// See [`IsolatedPythonPlugin::worker_capabilities`].
142///
143/// The host's vocabulary is the superset: `read_all`, `read_client`,
144/// `read_workload`, `read_inbound_credentials`, and `read_delegated_tokens` have
145/// no Python counterpart yet and are intentionally absent here. When the Python
146/// enum grows one, adding it here is what lets it cross the boundary.
147const WORKER_KNOWN_CAPABILITIES: &[&str] = &[
148    "read_subject",
149    "read_roles",
150    "read_teams",
151    "read_claims",
152    "read_permissions",
153    "read_agent",
154    "read_headers",
155    "write_headers",
156    "read_labels",
157    "append_labels",
158    "read_delegation",
159    "append_delegation",
160];
161
162/// Venv-relevant settings, parsed from a plugin entry's opaque `config` map.
163///
164/// `PluginConfig` has no fields for a class name, a content-size cap, or a
165/// per-plugin timeout — those are host-specific, so they live in the
166/// free-form `config` value and are parsed here.
167///
168/// # `plugin_dirs` is deliberately absent
169///
170/// A `plugin_dirs:` key in this block is **ignored**, as is the top-level
171/// `plugin_dirs:` YAML key. Plugin directories are not configurable: the host
172/// always uses `<project root>/plugins`, which is where `cpex plugin install`
173/// puts them. See `DEFAULT_PLUGIN_DIR`. Serde ignores unknown fields, so an
174/// existing config that still carries the key parses without error — the value
175/// simply has no effect.
176#[derive(Debug, Clone, Deserialize)]
177pub struct VenvConfig {
178    /// Fully-qualified Python class implementing the plugin, e.g.
179    /// `my_pkg.filters.PiiFilter`. Required: the worker needs it to import
180    /// and instantiate the plugin, and the venv cache is keyed on it.
181    pub class_name: String,
182
183    /// Requirements file to install into the venv, relative to the package
184    /// root. Optional — a plugin installed by FQN conversion gets its
185    /// dependencies from the install channel, not a requirements file, and
186    /// then the manifest version plus hash is the sole cache signal.
187    #[serde(default)]
188    pub requirements_file: Option<String>,
189
190    /// Worker script path inside the venv. Defaults to the framework's
191    /// `worker.py`; overridable for tests and for a host that vendors its own.
192    #[serde(default = "default_script_path")]
193    pub script_path: String,
194
195    /// Cap on one serialized outbound task, in bytes.
196    #[serde(default = "default_max_content_size")]
197    pub max_content_size: usize,
198
199    /// Per-invocation timeout, in seconds. Falls back to the framework
200    /// default when absent.
201    #[serde(default = "default_timeout_secs")]
202    pub timeout_secs: u64,
203}
204
205fn default_script_path() -> String {
206    DEFAULT_SCRIPT_PATH.to_string()
207}
208
209fn default_max_content_size() -> usize {
210    DEFAULT_MAX_CONTENT_SIZE
211}
212
213fn default_timeout_secs() -> u64 {
214    DEFAULT_TIMEOUT_SECS
215}
216
217impl VenvConfig {
218    /// Parse the venv settings out of a plugin entry's `config` map.
219    ///
220    /// A missing or malformed `class_name` is a config error, not a panic —
221    /// the manager surfaces it against the plugin name at load time.
222    pub fn from_plugin_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
223        let raw = config
224            .config
225            .clone()
226            .unwrap_or_else(|| serde_json::json!({}));
227
228        serde_json::from_value(raw).map_err(|e| {
229            PluginError::Config {
230                message: format!(
231                    "plugin '{}' (isolated_venv): invalid `config:` block — {}. \
232                     `class_name` is required (the fully-qualified Python class, \
233                     e.g. my_pkg.filters.PiiFilter)",
234                    config.name, e
235                ),
236            }
237            .boxed()
238        })
239    }
240}
241
242/// A Python plugin running out-of-process in its own virtualenv.
243pub struct IsolatedPythonPlugin {
244    /// The authoritative config entry, kept for `Plugin::config()`.
245    config: PluginConfig,
246
247    /// Venv settings parsed from the entry's `config` map.
248    venv_config: VenvConfig,
249
250    /// Directories the worker prepends to `sys.path`, and under whose first
251    /// entry the venv is built.
252    ///
253    /// Host-owned rather than config-derived: `<project root>/plugins`, per
254    /// `DEFAULT_PLUGIN_DIR`. Held here rather than recomputed per invocation so
255    /// the value stays fixed for the plugin's lifetime even if the process cwd
256    /// changes after construction.
257    plugin_dirs: Vec<String>,
258
259    /// Builds and caches the venv. `None` only when the layout cannot be
260    /// resolved (an empty dir list, or a `class_name` with no package
261    /// segment), which is valid solely for a `worker_override` test setup.
262    venv: Option<VenvManager>,
263
264    /// The live worker, populated by `initialize()`.
265    worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
266
267    /// Absolute worker-script path that bypasses venv resolution. Tests point
268    /// this at a stub; production leaves it unset and resolves from the venv.
269    worker_override: Option<PathBuf>,
270
271    /// Working directory for the worker process.
272    ///
273    /// Defaults to the host's own cwd, matching `venv_comm.py`'s
274    /// `cwd=os.getcwd()`. This is load-bearing twice over: the worker's
275    /// `ALLOWED_PLUGIN_DIRS` allowlist accepts `os.getcwd()`, so a plugin dir
276    /// outside it is rejected outright; and a plugin's relative-path side
277    /// effects land here.
278    worker_cwd: Option<PathBuf>,
279
280    /// Respawn bookkeeping for a worker that dies after a successful
281    /// `initialize()`.
282    respawn: tokio::sync::Mutex<RespawnState>,
283}
284
285/// How many respawns this plugin has spent, and when the budget last reset.
286///
287/// Held behind its own mutex rather than folded into the worker lock so the
288/// accounting survives the worker slot being replaced, and so two concurrent
289/// invocations that both notice the same death cooperate: the first through
290/// respawns, the second finds a live worker and spends nothing.
291#[derive(Debug)]
292struct RespawnState {
293    /// Respawn attempts spent since the last budget reset.
294    attempts: u32,
295    /// When the budget was last reset, for the [`RESPAWN_BUDGET_RESET_SECS`]
296    /// window. `None` until the first respawn.
297    window_started: Option<std::time::Instant>,
298}
299
300impl RespawnState {
301    fn new() -> Self {
302        Self {
303            attempts: 0,
304            window_started: None,
305        }
306    }
307
308    /// Reserve one respawn attempt, or report the budget is spent.
309    ///
310    /// Resets the counter first when the window has elapsed, so a plugin that
311    /// failed hard an hour ago is not still being punished for it.
312    fn try_reserve(&mut self) -> Option<u32> {
313        let now = std::time::Instant::now();
314        let expired = self
315            .window_started
316            .is_some_and(|s| now.duration_since(s).as_secs() >= RESPAWN_BUDGET_RESET_SECS);
317
318        if expired {
319            self.attempts = 0;
320            self.window_started = None;
321        }
322
323        if self.attempts >= MAX_RESPAWN_ATTEMPTS {
324            return None;
325        }
326
327        self.attempts += 1;
328        self.window_started.get_or_insert(now);
329        Some(self.attempts)
330    }
331
332    /// Return the budget to full after a respawn that produced a live worker.
333    fn record_success(&mut self) {
334        self.attempts = 0;
335        self.window_started = None;
336    }
337}
338
339impl IsolatedPythonPlugin {
340    /// Build a plugin from its config entry, parsing the venv settings.
341    pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
342        let venv_config = VenvConfig::from_plugin_config(config)?;
343
344        // Not read from the config block — see `DEFAULT_PLUGIN_DIR`. A
345        // `plugin_dirs:` key there (or at the YAML top level) is ignored;
346        // warning about one is the factory's job, since it can name the plugin.
347        let plugin_dirs = default_plugin_dirs();
348
349        // A layout that will not resolve leaves the venv absent. That is a
350        // config error in production, but the failure belongs in
351        // `initialize()` rather than here: the factory should still hand back a
352        // named plugin the manager can report against.
353        let venv = VenvManager::new(
354            &plugin_dirs,
355            &venv_config.class_name,
356            venv_config.requirements_file.as_deref(),
357            config.version.as_deref(),
358        )
359        .ok();
360
361        Ok(Self {
362            config: config.clone(),
363            venv_config,
364            plugin_dirs,
365            venv,
366            worker: tokio::sync::RwLock::new(None),
367            worker_override: None,
368            worker_cwd: None,
369            respawn: tokio::sync::Mutex::new(RespawnState::new()),
370        })
371    }
372
373    /// The resolved plugin directories — `<project root>/plugins`.
374    pub fn plugin_dirs(&self) -> &[String] {
375        &self.plugin_dirs
376    }
377
378    /// Run the worker with an explicit working directory.
379    ///
380    /// Production leaves this unset and inherits the host's cwd. Tests set it
381    /// so their scratch plugin dir falls inside the worker's
382    /// `ALLOWED_PLUGIN_DIRS` (which accepts `os.getcwd()`), and so a plugin's
383    /// marker files land somewhere the test can find and clean up.
384    #[cfg(any(test, feature = "testing"))]
385    pub fn with_worker_cwd(mut self, cwd: PathBuf) -> Self {
386        self.worker_cwd = Some(cwd);
387        self
388    }
389
390    /// Point the plugin at explicit plugin directories, replacing the
391    /// `<project root>/plugins` default.
392    ///
393    /// Test-only seam. Production has no override by design — the directory is
394    /// fixed so the host and `cpex plugin install` cannot disagree. The e2e
395    /// tests scaffold a plugin into a temp dir and need the venv built there
396    /// rather than in the developer's real `plugins/`, which a test must not
397    /// touch.
398    ///
399    /// Rebuilds the venv manager, since the layout is derived from these dirs.
400    #[cfg(any(test, feature = "testing"))]
401    pub fn with_plugin_dirs(mut self, dirs: Vec<String>) -> Self {
402        self.venv = VenvManager::new(
403            &dirs,
404            &self.venv_config.class_name,
405            self.venv_config.requirements_file.as_deref(),
406            self.config.version.as_deref(),
407        )
408        .ok();
409        self.plugin_dirs = dirs;
410        self
411    }
412
413    /// Point the plugin at an explicit worker script, skipping venv build and
414    /// script resolution.
415    ///
416    /// Test-only seam: the adapter tests need to exercise dispatch against a
417    /// stub worker without paying for a real venv, and the credential tests
418    /// need a worker that reports exactly what it received.
419    #[cfg(any(test, feature = "testing"))]
420    pub fn with_worker_override(mut self, script: PathBuf) -> Self {
421        self.worker_override = Some(script);
422        self
423    }
424
425    /// The parsed venv settings.
426    pub fn venv_config(&self) -> &VenvConfig {
427        &self.venv_config
428    }
429
430    /// The live worker's retained stderr, for leak assertions in tests.
431    ///
432    /// Returns an empty vector when no worker is running.
433    #[cfg(any(test, feature = "testing"))]
434    pub async fn worker_stderr(&self) -> Vec<String> {
435        match self.worker.read().await.as_ref() {
436            Some(worker) => worker.stderr_lines().await,
437            None => Vec::new(),
438        }
439    }
440
441    /// Launch a worker process for this plugin.
442    ///
443    /// The single path both `initialize()` and the respawn take, which is what
444    /// makes a respawned worker indistinguishable from the original: same
445    /// interpreter, same script, same cwd, same content cap, same timeout. The
446    /// security posture is preserved by construction rather than by a second
447    /// copy of the setup that could drift — the capability and credential
448    /// handshake is applied per invocation in `PythonHookAdapter::invoke`
449    /// against `self.config.capabilities`, so a fresh worker is gated by exactly
450    /// the same declared capabilities as the one it replaced. There is no
451    /// "degraded" or "retry" mode that relaxes it.
452    async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
453        // With an override the worker script is given outright, so neither a
454        // venv nor script resolution is needed — but the interpreter still is.
455        let (python, script) = match self.worker_override.as_ref() {
456            Some(script) => (which_python3()?, script.clone()),
457            None => {
458                let venv = self.venv.as_ref().ok_or_else(|| HostError::Config {
459                    message: format!(
460                        "could not resolve a venv layout under {} — check that `class_name` \
461                         ('{}') begins with a package segment",
462                        self.plugin_dirs.join(", "),
463                        self.venv_config.class_name,
464                    ),
465                })?;
466
467                venv.ensure().await?;
468                let script = crate::worker::resolve_worker_script(
469                    &venv.layout().venv_path,
470                    &self.venv_config.script_path,
471                )?;
472
473                (venv.python_executable(), script)
474            },
475        };
476
477        // Matching `venv_comm.py`'s `cwd=os.getcwd()`. The worker's
478        // ALLOWED_PLUGIN_DIRS allowlist accepts its own cwd, so inheriting the
479        // host's is what lets a plugin dir declared relative to the gateway be
480        // importable at all.
481        WorkerClient::spawn(
482            &python,
483            &script,
484            self.worker_cwd.as_deref(),
485            self.venv_config.max_content_size,
486            self.venv_config.timeout_secs,
487        )
488        .await
489    }
490
491    /// The live worker, respawning it when the previous one has died.
492    ///
493    /// A worker can die between invocations — an OOM kill, a segfault in a
494    /// native dependency, a plugin that calls `sys.exit`. Before this, the
495    /// plugin stayed permanently broken: every later invocation found the dead
496    /// `WorkerClient` still in the slot and failed against it, so one crash
497    /// disabled the plugin for the lifetime of the process.
498    ///
499    /// Respawn is bounded ([`MAX_RESPAWN_ATTEMPTS`]) and backed off, and a
500    /// plugin that exhausts its budget returns a typed `WorkerStart` error
501    /// rather than retrying forever.
502    async fn worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
503        let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
504
505        // The common path: a worker exists and is alive. No lock beyond the
506        // read, so a healthy invocation pays nothing for the respawn machinery.
507        if let Some(worker) = self.worker.read().await.clone() {
508            if worker.is_alive() {
509                return Ok(worker);
510            }
511        } else {
512            // Never initialized is not a respawn case — respawning here would
513            // paper over a lifecycle bug (or an initialize() that failed and
514            // was rolled back) by silently starting a worker the manager does
515            // not know about.
516            return Err(to_plugin_error(HostError::WorkerStart {
517                message: "plugin has no worker — initialize() did not run or it failed".into(),
518            }));
519        }
520
521        self.respawn_worker().await
522    }
523
524    /// Replace a dead worker, under the respawn lock.
525    ///
526    /// Split out so the happy path in `worker()` stays lock-free. Holding the
527    /// respawn mutex across the relaunch serializes concurrent invocations that
528    /// all noticed the same death: the first respawns, the rest re-check and
529    /// find a live worker without spending budget.
530    async fn respawn_worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
531        let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
532        let mut respawn = self.respawn.lock().await;
533
534        // Re-check under the lock. A concurrent invocation may already have
535        // done the work while this one waited.
536        if let Some(worker) = self.worker.read().await.clone() {
537            if worker.is_alive() {
538                return Ok(worker);
539            }
540        }
541
542        let Some(attempt) = respawn.try_reserve() else {
543            tracing::error!(
544                plugin = %self.config.name,
545                max_attempts = MAX_RESPAWN_ATTEMPTS,
546                "worker died and the respawn budget is exhausted; giving up"
547            );
548            return Err(to_plugin_error(HostError::WorkerStart {
549                message: format!(
550                    "worker died and could not be respawned after {MAX_RESPAWN_ATTEMPTS} attempts \
551                     — the plugin's worker is failing to start (check its venv and dependencies)"
552                ),
553            }));
554        };
555
556        // Exponential: 100ms, 200ms, 400ms. Applied *before* the spawn so a
557        // worker that dies instantly on launch cannot spin a tight loop.
558        let backoff = std::time::Duration::from_millis(
559            RESPAWN_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1)),
560        );
561        tracing::warn!(
562            plugin = %self.config.name,
563            attempt,
564            max_attempts = MAX_RESPAWN_ATTEMPTS,
565            backoff_ms = backoff.as_millis(),
566            "worker is dead; respawning"
567        );
568        tokio::time::sleep(backoff).await;
569
570        // Reap the corpse before replacing it, so the old process and its I/O
571        // tasks are not left behind on every respawn.
572        if let Some(dead) = self.worker.write().await.take() {
573            if let Err(e) = dead.shutdown().await {
574                tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
575            }
576        }
577
578        // Same launch path as `initialize()` — see `launch_worker`.
579        let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
580
581        // Re-verified rather than assumed from initialize(): the replacement is a
582        // fresh process resolving `worker.py` from the venv again, and the venv
583        // can have been rebuilt or downgraded since. Skipping this would make
584        // respawn the one path that installs an unverified worker.
585        self.verify_worker_understands(&worker)
586            .await
587            .map_err(to_plugin_error)?;
588
589        *self.worker.write().await = Some(Arc::clone(&worker));
590
591        // Deliberately *not* `record_success()` here.
592        //
593        // `is_alive()` reflects the reader task's last observation, and a worker
594        // that dies at import time has not necessarily been observed yet — it
595        // reports alive for the moment right after spawn. Resetting the budget
596        // on that would let a crash-looping worker refill its allowance on every
597        // attempt and retry forever, which is exactly what the bound exists to
598        // prevent. The budget is instead reset by the passage of time
599        // (`RESPAWN_BUDGET_RESET_SECS`), so recovery is still possible once the
600        // underlying cause is fixed, and by a *successful invocation* through
601        // `note_worker_healthy`, which is proof the worker answered rather than
602        // merely proof it launched.
603        tracing::info!(
604            plugin = %self.config.name,
605            attempt,
606            "worker respawned; awaiting proof it can serve a request"
607        );
608
609        Ok(worker)
610    }
611
612    /// Mark the worker healthy after it has actually answered a request.
613    ///
614    /// Called from the adapter on a successful round trip. A completed
615    /// invocation is the only evidence that distinguishes a worker that started
616    /// from a worker that works, so it — not a successful spawn — is what
617    /// returns the respawn budget to full.
618    async fn note_worker_healthy(&self) {
619        let mut respawn = self.respawn.lock().await;
620        if respawn.attempts > 0 {
621            respawn.record_success();
622        }
623    }
624
625    /// The declared capabilities, reduced to the ones the worker's
626    /// `PluginConfig` model will accept.
627    ///
628    /// The Python model's `capabilities` validator *raises* on a name its
629    /// `Capability` enum does not know, and Pydantic validation happens while
630    /// the worker is constructing the config — before the plugin is ever
631    /// called. This host's capability vocabulary is the larger of the two
632    /// (`read_client`, `read_workload`, `read_all`, and the two
633    /// `raw_credentials` caps have no Python counterpart yet), so forwarding
634    /// the set verbatim would take down every hook of any plugin declaring one
635    /// of them.
636    ///
637    /// Dropping the unknown names is safe in the direction that matters:
638    /// the extensions the worker receives were already filtered by the
639    /// executor, so a dropped capability can only make the worker's
640    /// re-filter *more* restrictive, never less. A slot this host chose not to
641    /// send cannot be recovered by a capability string. The reverse — keeping an
642    /// unknown name — trades a possibly-narrower view for a dead worker.
643    fn worker_capabilities(&self) -> Vec<String> {
644        let (known, unknown): (Vec<String>, Vec<String>) = self
645            .config
646            .capabilities
647            .iter()
648            .cloned()
649            .partition(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
650
651        if !unknown.is_empty() {
652            // Worth a line: it means this host's vocabulary has grown past the
653            // installed framework's, and a plugin declaring one of these sees a
654            // narrower view out-of-process than it would in-process.
655            tracing::warn!(
656                plugin = %self.config.name,
657                dropped = ?unknown,
658                "the installed cpex framework does not model these capabilities; \
659                 omitting them from the worker's config so it can still validate"
660            );
661        }
662
663        known
664    }
665
666    /// Fail closed unless the worker implements every wire feature this
667    /// plugin's declaration commits the host to using.
668    ///
669    /// # Why a version check would not do
670    ///
671    /// The host cannot tell an old `worker.py` from a current one by version:
672    /// two framework builds shipped as 0.1.2 with different worker protocols. An
673    /// old worker also does not *reject* a field it does not know — it ignores
674    /// it. So a plugin declaring `read_inbound_credentials` against such a
675    /// worker ran with no credential at all, silently, which is the exact case
676    /// fail-closed rule 2 exists to prevent. The worker is therefore asked
677    /// directly, and a worker that cannot answer is assumed to support nothing.
678    ///
679    /// Only features this plugin actually needs are required. A plugin that
680    /// declares no gated capability and runs no credential hook is served fine
681    /// by an old worker, and refusing to start it would be a regression for
682    /// every existing plugin.
683    async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
684        let required = self.required_worker_features();
685        if required.is_empty() {
686            return Ok(());
687        }
688
689        let reported = worker.capabilities().await?;
690        let missing: Vec<&str> = required
691            .iter()
692            .copied()
693            .filter(|f| !reported.has(f))
694            .collect();
695
696        if missing.is_empty() {
697            tracing::debug!(
698                plugin = %self.config.name,
699                protocol_version = reported.protocol_version,
700                cpex_version = ?reported.cpex_version,
701                "worker handshake satisfied"
702            );
703            return Ok(());
704        }
705
706        // Sorted so the diagnostic is stable across runs: `capabilities` is a
707        // HashSet, and an operator comparing two startup failures should not
708        // have to notice that only the ordering changed.
709        let mut declared: Vec<&str> = self
710            .config
711            .capabilities
712            .iter()
713            .map(String::as_str)
714            .collect();
715        declared.sort_unstable();
716
717        Err(HostError::Credential {
718            message: format!(
719                "the installed cpex worker does not implement {} — which plugin '{}' needs given \
720                 its declared capabilities ({}) and hooks ({}). The worker reported protocol \
721                 version {} and features [{}] (cpex {}). Running anyway would drop those fields \
722                 silently, so this plugin is failed closed at startup; upgrade the cpex package \
723                 in the plugin's venv.",
724                missing.join(", "),
725                self.config.name,
726                declared.join(", "),
727                self.config.hooks.join(", "),
728                reported.protocol_version,
729                reported
730                    .features
731                    .iter()
732                    .map(String::as_str)
733                    .collect::<Vec<_>>()
734                    .join(", "),
735                reported
736                    .cpex_version
737                    .as_deref()
738                    .unwrap_or("version unknown"),
739            ),
740        })
741    }
742
743    /// The wire features this plugin's declaration commits the host to using.
744    ///
745    /// Derived from the declaration rather than from what a given request
746    /// happens to carry, so the check is decidable at startup and does not vary
747    /// per invocation.
748    fn required_worker_features(&self) -> Vec<&'static str> {
749        let mut required = Vec::new();
750
751        // A credential hook only receives material when the matching capability
752        // is declared, so both halves are required for the field to travel —
753        // which makes the capability the precise trigger.
754        let declares_credential_cap = self.config.capabilities.iter().any(|c| {
755            c == crate::credentials::CAP_READ_INBOUND || c == crate::credentials::CAP_READ_DELEGATED
756        });
757        if declares_credential_cap {
758            required.push(crate::worker::FEATURE_CREDENTIAL);
759        }
760
761        // Any capability the worker models is a capability whose slot this host
762        // sends in the `extensions` field. A worker that drops the field leaves
763        // the hook with `extensions=None`, so the plugin silently sees nothing
764        // of what it declared.
765        let declares_extension_cap = self
766            .config
767            .capabilities
768            .iter()
769            .any(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
770        if declares_extension_cap {
771            required.push(crate::worker::FEATURE_EXTENSIONS);
772        }
773
774        required
775    }
776
777    /// Build the `load_and_run_hook` task for one invocation.
778    ///
779    /// The field names and encodings are the protocol contract with
780    /// `worker.py`: `config` is a JSON *string* the worker `json.loads`es into
781    /// `PluginConfig(**config_raw)`, and `context` is the worker's own
782    /// `{state, global_context, metadata}` shape rather than the Rust
783    /// `PluginContext` layout.
784    fn build_task(
785        &self,
786        hook_name: &str,
787        payload: serde_json::Value,
788        ctx: &PluginContext,
789    ) -> Result<serde_json::Value, HostError> {
790        // The worker constructs a Python `PluginConfig` from this, so it must
791        // carry that model's field names — not this host's venv settings.
792        //
793        // `capabilities` has to travel even though this host already applies the
794        // executor's filtered view: the Python manager re-filters against
795        // `plugin_ref.capabilities` before it calls a 3-arg hook
796        // (`cpex/framework/manager.py`, `_execute_with_timeout`). Omitting the
797        // field left that set empty, so the second filter stripped every gated
798        // slot the first one had just allowed — a 3-arg plugin saw
799        // `security.labels` empty and `http` as `None` no matter what it
800        // declared. The two filters are now given the same input, so the second
801        // one is idempotent rather than destructive.
802        let config_json = serde_json::json!({
803            "name": self.config.name,
804            "kind": self.config.kind,
805            "hooks": self.config.hooks,
806            "capabilities": self.worker_capabilities(),
807            "config": self.config.config.clone().unwrap_or(serde_json::Value::Null),
808        });
809        let config_string =
810            serde_json::to_string(&config_json).map_err(|e| HostError::Protocol {
811                message: format!("could not serialize the plugin config for the worker: {e}"),
812            })?;
813
814        // Prefer a request id the pipeline already put in global state, so a
815        // plugin's logs correlate with the gateway's. `PluginContext` has no
816        // dedicated field for one, so fall back to a fresh UUID rather than
817        // sending a placeholder that would collide across requests.
818        let request_id = ctx
819            .get_global(GLOBAL_REQUEST_ID_KEY)
820            .and_then(serde_json::Value::as_str)
821            .map(str::to_string)
822            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
823
824        Ok(serde_json::json!({
825            "task_type": crate::worker::TASK_LOAD_AND_RUN_HOOK,
826            "plugin_dirs": self.plugin_dirs,
827            "class_name": self.venv_config.class_name,
828            "config": config_string,
829            "hook_type": hook_name,
830            "plugin_name": self.config.name,
831            "payload": payload,
832            "context": {
833                // `local_state` is this plugin's private slice; `global_state`
834                // is the pipeline-wide map the worker exposes as
835                // `global_context`.
836                "state": ctx.local_state,
837                "global_context": {
838                    // `request_id` is the one *required* field on the Python
839                    // `GlobalContext`; omitting it fails Pydantic validation
840                    // inside the worker before the plugin is ever called. The
841                    // Rust `PluginContext` carries no request id, so one is
842                    // synthesized per invocation — a plugin that correlates
843                    // logs by request id still gets a stable, unique value for
844                    // the call.
845                    "request_id": request_id,
846                    "state": ctx.global_state,
847                },
848                "metadata": {},
849            },
850        }))
851    }
852}
853
854#[async_trait]
855impl Plugin for IsolatedPythonPlugin {
856    fn config(&self) -> &PluginConfig {
857        &self.config
858    }
859
860    /// Build the venv (or reuse a cached one) and launch the worker.
861    ///
862    /// Heavy setup lives here rather than on the invoke path: the manager
863    /// awaits this once per plugin, with rollback on failure.
864    async fn initialize(&self) -> Result<(), Box<PluginError>> {
865        let worker = self
866            .launch_worker()
867            .await
868            .map_err(|e| e.into_plugin_error(&self.config.name))?;
869
870        // Verify the worker understands what this plugin's declaration commits
871        // us to sending, before it is installed as the live worker. A mismatch
872        // is a startup failure, not a per-request one: the plugin cannot be
873        // served safely at all, and leaving it registered would mean discovering
874        // that on a live request instead.
875        self.verify_worker_understands(&worker)
876            .await
877            .map_err(|e| e.into_plugin_error(&self.config.name))?;
878
879        *self.worker.write().await = Some(Arc::new(worker));
880        Ok(())
881    }
882
883    /// Stop the worker, killing it if it will not exit on its own.
884    async fn shutdown(&self) -> Result<(), Box<PluginError>> {
885        let worker = self.worker.write().await.take();
886        let result = match worker {
887            Some(worker) => worker
888                .shutdown()
889                .await
890                .map_err(|e| e.into_plugin_error(&self.config.name)),
891            None => Ok(()),
892        };
893
894        // Give up this plugin's claim on the (possibly shared) venv, so a later
895        // rebuild is not permanently downgraded to an in-place update by a stale
896        // owner entry. Best-effort: a registry write failure must not turn a
897        // clean shutdown into an error, since the venv itself is fine.
898        if let Some(venv) = self.venv.as_ref() {
899            if let Err(e) = venv.release_owner().await {
900                tracing::warn!(
901                    plugin = %self.config.name,
902                    "could not release the venv owner claim: {e}"
903                );
904            }
905        }
906
907        result
908    }
909}
910
911/// Resolve an absolute `python3` from PATH.
912///
913/// `WorkerClient::spawn` takes an interpreter path rather than doing a PATH
914/// lookup, so the override path needs one. Production goes through the venv's
915/// own interpreter and never calls this.
916fn which_python3() -> Result<PathBuf, HostError> {
917    let candidates = [
918        "/usr/bin/python3",
919        "/usr/local/bin/python3",
920        "/opt/homebrew/bin/python3",
921    ];
922    if let Some(found) = candidates.iter().map(Path::new).find(|p| p.exists()) {
923        return Ok(found.to_path_buf());
924    }
925
926    // Fall back to asking the shell, which honors PATH (pyenv, conda, nix).
927    let output = std::process::Command::new("sh")
928        .args(["-c", "command -v python3"])
929        .output()
930        .map_err(|e| HostError::WorkerStart {
931            message: format!("could not look up python3: {e}"),
932        })?;
933
934    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
935    if path.is_empty() {
936        return Err(HostError::WorkerStart {
937            message: "python3 was not found on PATH".into(),
938        });
939    }
940    Ok(PathBuf::from(path))
941}
942
943/// Handler for one hook of one Python plugin.
944///
945/// Holds the hook name because a single plugin object serves every hook it
946/// declared, and the worker needs to be told which one to run.
947pub struct PythonHookAdapter {
948    plugin: Arc<IsolatedPythonPlugin>,
949    hook_name: &'static str,
950}
951
952impl PythonHookAdapter {
953    pub fn new(plugin: Arc<IsolatedPythonPlugin>, hook_name: &'static str) -> Self {
954        Self { plugin, hook_name }
955    }
956}
957
958#[async_trait]
959impl AnyHookHandler for PythonHookAdapter {
960    /// Serialize, send, and convert one hook invocation.
961    ///
962    /// Every failure returns a `PluginError` and stops there — the *executor*
963    /// applies the plugin's configured `on_error` policy (fail / ignore /
964    /// disable). This host deliberately does not interpret that policy itself;
965    /// doing so would double-apply it.
966    async fn invoke(
967        &self,
968        payload: &dyn PluginPayload,
969        extensions: &Extensions,
970        ctx: &mut PluginContext,
971    ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
972        let plugin_name = &self.plugin.config.name;
973        let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name);
974
975        let payload_json = conversion::serialize_payload(payload).map_err(to_plugin_error)?;
976        let mut task = self
977            .plugin
978            .build_task(self.hook_name, payload_json, ctx)
979            .map_err(to_plugin_error)?;
980
981        // Raw credentials, for the two hooks that carry them and only for a
982        // plugin that declared the matching capability. Fails closed.
983        credentials::attach_credential(
984            &mut task,
985            self.hook_name,
986            extensions,
987            &self.plugin.config.capabilities,
988        )
989        .map_err(to_plugin_error)?;
990
991        // The capability-filtered extensions, so a 3-arg
992        // `(payload, context, extensions)` hook sees out-of-process what it
993        // would see in-process. Attaches nothing when no slot is visible.
994        crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?;
995
996        let worker = self.plugin.worker().await?;
997        let response = worker.send_task(task).await.map_err(to_plugin_error)?;
998
999        // A completed round trip is the only proof a (possibly respawned) worker
1000        // actually serves requests, so it is what returns the respawn budget.
1001        self.plugin.note_worker_healthy().await;
1002
1003        // State the worker sends back is merged into the caller's context
1004        // before the result is returned, so a plugin's context writes survive.
1005        apply_context_deltas(&response, ctx);
1006
1007        // `extensions` is passed back in so the returned-extensions path can
1008        // reuse the inbound `Arc`s for immutable slots and read the write
1009        // tokens the executor issued. The executor's copy-on-write merge then
1010        // validates the result against the mutability tiers.
1011        let fields = conversion::response_to_result(self.hook_name, response, extensions)
1012            .map_err(to_plugin_error)?;
1013        Ok(Box::new(fields))
1014    }
1015
1016    fn hook_type_name(&self) -> &'static str {
1017        self.hook_name
1018    }
1019}
1020
1021/// Merge worker-returned context state back into the mutable plugin context.
1022///
1023/// The worker returns the context under the same `{state, global_context}`
1024/// shape it received. Merging rather than replacing is deliberate: a plugin
1025/// that touched one key must not blank out the rest of the pipeline's state.
1026fn apply_context_deltas(response: &serde_json::Value, ctx: &mut PluginContext) {
1027    let Some(context) = response.get("context") else {
1028        return;
1029    };
1030
1031    if let Some(state) = context.get("state").and_then(|v| v.as_object()) {
1032        for (key, value) in state {
1033            ctx.local_state.insert(key.clone(), value.clone());
1034        }
1035    }
1036
1037    if let Some(global) = context
1038        .get("global_context")
1039        .and_then(|g| g.get("state"))
1040        .and_then(|v| v.as_object())
1041    {
1042        for (key, value) in global {
1043            ctx.global_state.insert(key.clone(), value.clone());
1044        }
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use crate::testing::TempDir;
1051
1052    use super::*;
1053
1054    fn config_with(value: serde_json::Value) -> PluginConfig {
1055        PluginConfig {
1056            name: "py-plugin".into(),
1057            kind: crate::factory::KIND.into(),
1058            hooks: vec!["tool_pre_invoke".into()],
1059            config: Some(value),
1060            ..Default::default()
1061        }
1062    }
1063
1064    #[test]
1065    fn full_config_block_deserializes_every_field() {
1066        let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1067            "class_name": "my_pkg.filters.PiiFilter",
1068            "requirements_file": "requirements.txt",
1069            "plugin_dirs": ["./plugins", "./vendor"],
1070            "script_path": "custom/worker.py",
1071            "max_content_size": 2048,
1072            "timeout_secs": 5,
1073        })))
1074        .expect("a fully populated block parses");
1075
1076        assert_eq!(cfg.class_name, "my_pkg.filters.PiiFilter");
1077        assert_eq!(cfg.requirements_file.as_deref(), Some("requirements.txt"));
1078        assert_eq!(cfg.script_path, "custom/worker.py");
1079        assert_eq!(cfg.max_content_size, 2048);
1080        assert_eq!(cfg.timeout_secs, 5);
1081        // The block above also carries `plugin_dirs`, which is no longer a
1082        // field — an unknown key must parse harmlessly rather than error, so
1083        // an existing config keeps loading.
1084    }
1085
1086    #[test]
1087    fn absent_optionals_fall_back_to_documented_defaults() {
1088        let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1089            "class_name": "my_pkg.Minimal",
1090        })))
1091        .expect("class_name alone is a valid block");
1092
1093        // These three defaults are the contract with `client.py` /
1094        // `venv_comm.py` — a drift here changes behavior silently.
1095        assert_eq!(
1096            cfg.max_content_size, 10_000_000,
1097            "matches client.py's max_content_size"
1098        );
1099        assert_eq!(
1100            cfg.timeout_secs, 30,
1101            "matches venv_comm.py's send_task timeout"
1102        );
1103        assert_eq!(cfg.script_path, "cpex/framework/isolated/worker.py");
1104
1105        assert!(
1106            cfg.requirements_file.is_none(),
1107            "no requirements file is valid"
1108        );
1109    }
1110
1111    #[test]
1112    fn missing_class_name_errors() {
1113        let err = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
1114            "plugin_dirs": ["./plugins"],
1115        })))
1116        .expect_err("class_name is required");
1117
1118        assert!(matches!(*err, PluginError::Config { .. }));
1119        assert!(err.to_string().contains("class_name"));
1120    }
1121
1122    #[test]
1123    fn absent_config_block_errors_rather_than_panicking() {
1124        let cfg = PluginConfig {
1125            name: "no-config".into(),
1126            kind: crate::factory::KIND.into(),
1127            hooks: vec!["tool_pre_invoke".into()],
1128            config: None,
1129            ..Default::default()
1130        };
1131
1132        let err = VenvConfig::from_plugin_config(&cfg)
1133            .expect_err("an absent block cannot supply class_name");
1134        assert!(matches!(*err, PluginError::Config { .. }));
1135    }
1136
1137    /// The plugin dir a config with no usable `plugin_dirs` anywhere resolves
1138    /// to: `<cwd>/plugins`.
1139    fn expected_default_dir() -> String {
1140        std::env::current_dir()
1141            .unwrap()
1142            .join("plugins")
1143            .display()
1144            .to_string()
1145    }
1146
1147    #[test]
1148    fn plugin_dirs_default_to_plugins_at_the_project_root() {
1149        // Neither key is set anywhere, which is the shape the host now expects.
1150        let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1151            "class_name": "my_pkg.Minimal",
1152        })))
1153        .expect("class_name alone is a valid block");
1154
1155        assert_eq!(
1156            plugin.plugin_dirs(),
1157            [expected_default_dir()],
1158            "the host supplies the plugin dir; the config no longer can"
1159        );
1160        // Absolute, so a later cwd change cannot move the venv out from under
1161        // a running plugin.
1162        assert!(
1163            Path::new(&plugin.plugin_dirs()[0]).is_absolute(),
1164            "the resolved dir must be absolute: {:?}",
1165            plugin.plugin_dirs()
1166        );
1167    }
1168
1169    #[test]
1170    fn a_plugin_dirs_key_in_the_config_block_is_ignored() {
1171        // The key an existing config still carries. It must not steer the host,
1172        // and it must not fail the parse either — an operator upgrading should
1173        // see the warning (emitted by the factory) rather than a load error.
1174        let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1175            "class_name": "my_pkg.Minimal",
1176            "plugin_dirs": ["/somewhere/else", "/and/here"],
1177        })))
1178        .expect("an ignored key must not fail the parse");
1179
1180        assert_eq!(
1181            plugin.plugin_dirs(),
1182            [expected_default_dir()],
1183            "`plugin_dirs` in the config block must be ignored"
1184        );
1185    }
1186
1187    #[test]
1188    fn the_top_level_plugin_dirs_key_is_also_ignored() {
1189        // `cpex plugin install` writes the key here. cpex_core parses and
1190        // discards it; this pins that it does not reach the host either.
1191        let yaml = r#"
1192plugin_dirs: ["./top-level-dirs"]
1193plugins:
1194  - name: py-plugin
1195    kind: isolated_venv
1196    hooks: [tool_pre_invoke]
1197    config:
1198      class_name: my_pkg.Minimal
1199"#;
1200        let cpex_config: cpex_core::config::CpexConfig =
1201            serde_yaml::from_str(yaml).expect("valid YAML");
1202        assert_eq!(
1203            cpex_config.plugin_dirs,
1204            vec!["./top-level-dirs"],
1205            "the top-level key still parses — it is simply not the host's source"
1206        );
1207
1208        let plugin =
1209            IsolatedPythonPlugin::from_config(&cpex_config.plugins[0]).expect("config parses");
1210        assert_eq!(
1211            plugin.plugin_dirs(),
1212            [expected_default_dir()],
1213            "the top-level key must not steer the host's plugin dir"
1214        );
1215    }
1216
1217    #[test]
1218    fn the_resolved_plugin_dir_is_what_the_worker_receives() {
1219        // The dir has to reach the worker's `sys.path`, not just the venv
1220        // layout — otherwise the venv is built in the right place and the
1221        // import still fails.
1222        let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
1223            "class_name": "my_pkg.Minimal",
1224            "plugin_dirs": ["/ignored"],
1225        })))
1226        .expect("parses");
1227
1228        let task = plugin
1229            .build_task(
1230                "tool_pre_invoke",
1231                serde_json::json!({"name": "t"}),
1232                &PluginContext::new(),
1233            )
1234            .expect("task builds");
1235
1236        assert_eq!(
1237            task["plugin_dirs"],
1238            serde_json::json!([expected_default_dir()]),
1239            "the worker must be told the resolved dir, not the ignored one"
1240        );
1241    }
1242
1243    /// Parse the `config` field, which travels as a JSON *string*.
1244    fn worker_config(task: &serde_json::Value) -> serde_json::Value {
1245        serde_json::from_str(task["config"].as_str().expect("config is a string"))
1246            .expect("the worker's config parses")
1247    }
1248
1249    /// Build a task for a plugin declaring `capabilities`.
1250    fn task_for_capabilities(capabilities: &[&str]) -> serde_json::Value {
1251        let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
1252        config.capabilities = capabilities.iter().map(|c| (*c).to_string()).collect();
1253
1254        IsolatedPythonPlugin::from_config(&config)
1255            .expect("parses")
1256            .build_task(
1257                "tool_pre_invoke",
1258                serde_json::json!({"name": "t"}),
1259                &PluginContext::new(),
1260            )
1261            .expect("task builds")
1262    }
1263
1264    #[test]
1265    fn declared_capabilities_reach_the_workers_config() {
1266        // The Python manager re-filters extensions against the capabilities in
1267        // *its* config before calling a 3-arg hook. Send none and that filter
1268        // runs against an empty set, stripping every gated slot the executor
1269        // just allowed — which is how a plugin declaring `read_labels` ended up
1270        // seeing an empty label set out-of-process.
1271        let config = worker_config(&task_for_capabilities(&["read_labels", "append_labels"]));
1272
1273        let mut sent: Vec<&str> = config["capabilities"]
1274            .as_array()
1275            .expect("capabilities is an array")
1276            .iter()
1277            .map(|c| c.as_str().expect("a string"))
1278            .collect();
1279        sent.sort_unstable();
1280
1281        assert_eq!(
1282            sent,
1283            ["append_labels", "read_labels"],
1284            "the worker must be told what the plugin declared"
1285        );
1286    }
1287
1288    #[test]
1289    fn a_capability_the_python_model_rejects_is_not_forwarded() {
1290        // `PluginConfig`'s validator raises on an unknown capability, and it
1291        // does so while the worker builds the config — before the plugin runs.
1292        // So forwarding a host-only name costs every hook of that plugin, not
1293        // just the slot it gates. Dropping it can only narrow the view, which
1294        // the executor's own filter has already done.
1295        let config = worker_config(&task_for_capabilities(&["read_labels", "read_client"]));
1296
1297        assert_eq!(
1298            config["capabilities"],
1299            serde_json::json!(["read_labels"]),
1300            "a name the installed framework cannot validate must be dropped, \
1301             not sent and not escalated into a task failure"
1302        );
1303    }
1304
1305    #[test]
1306    fn a_plugin_declaring_nothing_sends_an_empty_capability_list() {
1307        // The field is always present so the worker's config shape does not
1308        // depend on whether anything was declared; an empty list and an absent
1309        // field mean the same thing to Pydantic, and the explicit one is easier
1310        // to read off the wire when diagnosing a filtering question.
1311        let config = worker_config(&task_for_capabilities(&[]));
1312
1313        assert_eq!(
1314            config["capabilities"],
1315            serde_json::json!([]),
1316            "no capabilities is an empty list, not a missing field"
1317        );
1318    }
1319
1320    // --- adapter dispatch ---------------------------------------------------
1321
1322    /// A stub worker that answers `load_and_run_hook` with a canned result and
1323    /// records the task it received.
1324    ///
1325    /// Behavior is driven by a marker file so a single script covers every
1326    /// case: the test writes `mode` next to the script before invoking.
1327    const ADAPTER_STUB: &str = r#"
1328import json, os, sys
1329
1330here = os.path.dirname(os.path.abspath(__file__))
1331
1332def mode_file(name, default=""):
1333    try:
1334        with open(os.path.join(here, name)) as f:
1335            return f.read().strip()
1336    except FileNotFoundError:
1337        return default
1338
1339def mode():
1340    return mode_file("mode", "allow")
1341
1342while True:
1343    line = sys.stdin.readline()
1344    if not line:
1345        break
1346    line = line.strip()
1347    if not line:
1348        continue
1349    task = json.loads(line)
1350    rid = task.get("request_id", "unknown")
1351
1352    if task.get("task_type") == "shutdown":
1353        print(json.dumps({"status": "success", "request_id": rid}), flush=True)
1354        break
1355
1356    # Answered before the recording below, so the spawn-time handshake does not
1357    # overwrite the `last_task.json` the tests assert their hook task against.
1358    # This stub stands in for a *current* worker, so it claims the features the
1359    # real one implements; `handshake_mode` covers the old-worker case.
1360    if task.get("task_type") == "capabilities":
1361        hs = mode_file("handshake_mode")
1362        if hs == "unsupported":
1363            # Exactly what a worker predating the handshake replies.
1364            print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True)
1365            continue
1366        features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"]
1367        print(json.dumps({
1368            "status": "success", "protocol_version": 1,
1369            "features": features, "cpex_version": "0.1.2", "request_id": rid,
1370        }), flush=True)
1371        continue
1372
1373    # Record the task so the test can assert on what the host actually sent.
1374    with open(os.path.join(here, "last_task.json"), "w") as f:
1375        json.dump(task, f)
1376
1377    # ...and which process served it, so a respawn test can prove the request
1378    # was answered by a *new* worker rather than by the original recovering.
1379    with open(os.path.join(here, "last_pid"), "w") as f:
1380        f.write(str(os.getpid()))
1381
1382    m = mode()
1383
1384    # `die` exits without replying, which is how a test kills the worker so the
1385    # *next* invocation has a dead one to respawn.
1386    if m == "die":
1387        sys.exit(9)
1388
1389    if m == "deny":
1390        response = {
1391            "continue_processing": False,
1392            "violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}},
1393        }
1394    elif m == "error":
1395        response = {"status": "error", "message": "the plugin raised ValueError"}
1396    elif m == "context":
1397        response = {
1398            "continue_processing": True,
1399            "context": {"state": {"seen": 1}, "global_context": {"state": {"shared": "yes"}}},
1400        }
1401    elif m == "modify":
1402        response = {
1403            "continue_processing": True,
1404            "modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}},
1405        }
1406    else:
1407        response = {"continue_processing": True}
1408
1409    response["request_id"] = rid
1410    print(json.dumps(response), flush=True)
1411"#;
1412
1413    /// Build a plugin wired to the adapter stub, plus its scratch dir.
1414    async fn adapter_plugin(
1415        hooks: &[&str],
1416        capabilities: &[&str],
1417    ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1418        let (dir, plugin) = uninitialized_adapter_plugin(hooks, capabilities);
1419        plugin.initialize().await.expect("the stub worker launches");
1420        (dir, plugin)
1421    }
1422
1423    fn set_mode(dir: &TempDir, mode: &str) {
1424        std::fs::write(dir.path().join("mode"), mode).unwrap();
1425    }
1426
1427    /// How the stub should answer the spawn-time `capabilities` handshake.
1428    ///
1429    /// `"unsupported"` reproduces a worker predating the handshake;
1430    /// `"no_features"` a worker that knows the task but implements nothing.
1431    fn set_handshake_mode(dir: &TempDir, mode: &str) {
1432        std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
1433    }
1434
1435    /// Build a plugin wired to the adapter stub *without* initializing it.
1436    ///
1437    /// Splits the scratch dir from `initialize()` so a test can set the stub's
1438    /// handshake mode first and then assert on the startup result — which
1439    /// `adapter_plugin` cannot, since it unwraps.
1440    fn uninitialized_adapter_plugin(
1441        hooks: &[&str],
1442        capabilities: &[&str],
1443    ) -> (TempDir, Arc<IsolatedPythonPlugin>) {
1444        let dir = TempDir::new();
1445        let script = dir.path().join("adapter_stub.py");
1446        std::fs::write(&script, ADAPTER_STUB).unwrap();
1447
1448        let config = PluginConfig {
1449            name: "py-plugin".into(),
1450            kind: crate::factory::KIND.into(),
1451            hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
1452            capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(),
1453            config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
1454            ..Default::default()
1455        };
1456
1457        let plugin = Arc::new(
1458            IsolatedPythonPlugin::from_config(&config)
1459                .expect("config parses")
1460                .with_worker_override(script),
1461        );
1462        (dir, plugin)
1463    }
1464
1465    fn last_task(dir: &TempDir) -> serde_json::Value {
1466        let raw = std::fs::read_to_string(dir.path().join("last_task.json"))
1467            .expect("the stub recorded a task");
1468        serde_json::from_str(&raw).expect("recorded task is JSON")
1469    }
1470
1471    /// Invoke a hook through the adapter and return the erased result fields.
1472    async fn invoke(
1473        plugin: &Arc<IsolatedPythonPlugin>,
1474        hook: &'static str,
1475        payload: &dyn PluginPayload,
1476        ctx: &mut PluginContext,
1477    ) -> Result<cpex_core::executor::ErasedResultFields, Box<PluginError>> {
1478        let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook);
1479        let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?;
1480        Ok(cpex_core::executor::extract_erased(erased)
1481            .expect("the adapter returns ErasedResultFields"))
1482    }
1483
1484    #[tokio::test]
1485    async fn an_invoke_round_trips_and_returns_allow() {
1486        if crate::testing::skip_without_python3("an_invoke_round_trips_and_returns_allow") {
1487            return;
1488        }
1489        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1490        set_mode(&dir, "allow");
1491
1492        let payload = crate::legacy::ToolPreInvokePayload {
1493            name: "search".into(),
1494            args: Some(std::collections::HashMap::from([(
1495                "q".into(),
1496                serde_json::json!("rust"),
1497            )])),
1498            headers: None,
1499        };
1500        let mut ctx = PluginContext::new();
1501        let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1502            .await
1503            .expect("the round trip succeeds");
1504
1505        assert!(fields.continue_processing);
1506        assert!(fields.violation.is_none());
1507
1508        // The task the worker received must carry the protocol contract.
1509        let task = last_task(&dir);
1510        assert_eq!(task["task_type"], "load_and_run_hook");
1511        assert_eq!(task["hook_type"], "tool_pre_invoke");
1512        assert_eq!(task["plugin_name"], "py-plugin");
1513        assert_eq!(task["class_name"], "my_pkg.Plugin");
1514        assert_eq!(task["payload"]["name"], "search");
1515        assert_eq!(task["payload"]["args"]["q"], "rust");
1516
1517        // `config` is a JSON *string* the worker json.loads()es — not an object.
1518        let config_str = task["config"]
1519            .as_str()
1520            .expect("config must be a JSON string");
1521        let config: serde_json::Value = serde_json::from_str(config_str).unwrap();
1522        assert_eq!(config["name"], "py-plugin");
1523
1524        plugin.shutdown().await.expect("shuts down");
1525    }
1526
1527    #[tokio::test]
1528    async fn a_deny_response_becomes_a_deny_result_with_its_violation() {
1529        if crate::testing::skip_without_python3(
1530            "a_deny_response_becomes_a_deny_result_with_its_violation",
1531        ) {
1532            return;
1533        }
1534        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1535        set_mode(&dir, "deny");
1536
1537        let payload = crate::legacy::ToolPreInvokePayload {
1538            name: "search".into(),
1539            ..Default::default()
1540        };
1541        let mut ctx = PluginContext::new();
1542        let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1543            .await
1544            .expect("a deny is a successful invocation that returns a deny");
1545
1546        assert!(!fields.continue_processing);
1547        let violation = fields.violation.expect("a deny must carry its violation");
1548        assert_eq!(violation.code, "PII_DETECTED");
1549        assert_eq!(violation.reason, "email in args");
1550        assert_eq!(violation.details["field"], "q");
1551
1552        plugin.shutdown().await.unwrap();
1553    }
1554
1555    #[tokio::test]
1556    async fn a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police() {
1557        // The host returns the error and stops. Applying the on_error policy is
1558        // the executor's job — doing it here would double-apply it.
1559        if crate::testing::skip_without_python3(
1560            "a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police",
1561        ) {
1562            return;
1563        }
1564        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1565        set_mode(&dir, "error");
1566
1567        let payload = crate::legacy::ToolPreInvokePayload {
1568            name: "search".into(),
1569            ..Default::default()
1570        };
1571        let mut ctx = PluginContext::new();
1572        let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
1573            panic!("a worker error response must not read as success");
1574        };
1575
1576        match *err {
1577            PluginError::Execution {
1578                ref plugin_name,
1579                ref code,
1580                ref message,
1581                ..
1582            } => {
1583                assert_eq!(plugin_name, "py-plugin");
1584                assert_eq!(code.as_deref(), Some("worker_error"));
1585                assert!(
1586                    message.contains("ValueError"),
1587                    "the plugin's message should survive: {message}"
1588                );
1589            },
1590            ref other => panic!("expected an Execution error, got {other:?}"),
1591        }
1592
1593        plugin.shutdown().await.unwrap();
1594    }
1595
1596    #[tokio::test]
1597    async fn context_deltas_are_written_back_into_the_plugin_context() {
1598        if crate::testing::skip_without_python3(
1599            "context_deltas_are_written_back_into_the_plugin_context",
1600        ) {
1601            return;
1602        }
1603        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1604        set_mode(&dir, "context");
1605
1606        let payload = crate::legacy::ToolPreInvokePayload {
1607            name: "search".into(),
1608            ..Default::default()
1609        };
1610        let mut ctx = PluginContext::new();
1611        ctx.set_local("preexisting", serde_json::json!("kept"));
1612
1613        invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1614            .await
1615            .unwrap();
1616
1617        assert_eq!(ctx.get_local("seen"), Some(&serde_json::json!(1)));
1618        assert_eq!(ctx.get_global("shared"), Some(&serde_json::json!("yes")));
1619        assert_eq!(
1620            ctx.get_local("preexisting"),
1621            Some(&serde_json::json!("kept")),
1622            "deltas merge — a plugin touching one key must not blank the rest"
1623        );
1624
1625        plugin.shutdown().await.unwrap();
1626    }
1627
1628    #[tokio::test]
1629    async fn a_modified_payload_comes_back_as_the_hooks_typed_payload() {
1630        if crate::testing::skip_without_python3(
1631            "a_modified_payload_comes_back_as_the_hooks_typed_payload",
1632        ) {
1633            return;
1634        }
1635        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1636        set_mode(&dir, "modify");
1637
1638        let payload = crate::legacy::ToolPreInvokePayload {
1639            name: "search".into(),
1640            ..Default::default()
1641        };
1642        let mut ctx = PluginContext::new();
1643        let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1644            .await
1645            .unwrap();
1646
1647        let modified = fields.modified_payload.expect("the modification survives");
1648        let typed = modified
1649            .as_any()
1650            .downcast_ref::<crate::legacy::ToolPreInvokePayload>()
1651            .expect("must come back as the tool payload or the executor's downcast fails");
1652        assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
1653
1654        plugin.shutdown().await.unwrap();
1655    }
1656
1657    #[tokio::test]
1658    async fn a_cmf_hook_sends_the_message_payload_shape() {
1659        if crate::testing::skip_without_python3("a_cmf_hook_sends_the_message_payload_shape") {
1660            return;
1661        }
1662        let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
1663        set_mode(&dir, "allow");
1664
1665        let payload = cpex_core::cmf::MessagePayload {
1666            message: cpex_core::cmf::Message::text(cpex_core::cmf::Role::User, "hello"),
1667        };
1668        let mut ctx = PluginContext::new();
1669        invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
1670            .await
1671            .unwrap();
1672
1673        let task = last_task(&dir);
1674        assert_eq!(task["hook_type"], "cmf.tool_pre_invoke");
1675        assert_eq!(
1676            task["payload"]["message"]["role"], "user",
1677            "a cmf hook must send the CMF message payload, not the generic wrapper"
1678        );
1679
1680        plugin.shutdown().await.unwrap();
1681    }
1682
1683    #[tokio::test]
1684    async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() {
1685        let config = PluginConfig {
1686            name: "uninitialized".into(),
1687            kind: crate::factory::KIND.into(),
1688            hooks: vec!["tool_pre_invoke".into()],
1689            config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
1690            ..Default::default()
1691        };
1692        let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
1693
1694        let payload = crate::legacy::ToolPreInvokePayload {
1695            name: "search".into(),
1696            ..Default::default()
1697        };
1698        let mut ctx = PluginContext::new();
1699        let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
1700            panic!("dispatch without a worker cannot succeed");
1701        };
1702        assert!(matches!(*err, PluginError::Execution { .. }));
1703    }
1704
1705    #[tokio::test]
1706    async fn a_credential_capability_attaches_the_dto_on_an_identity_hook() {
1707        // Ties the capability gate to real dispatch: the DTO must reach the
1708        // worker's task, on the contract's field name.
1709        if crate::testing::skip_without_python3(
1710            "a_credential_capability_attaches_the_dto_on_an_identity_hook",
1711        ) {
1712            return;
1713        }
1714        use cpex_core::extensions::raw_credentials::{
1715            RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1716        };
1717
1718        let (dir, plugin) =
1719            adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
1720        set_mode(&dir, "allow");
1721
1722        let mut raw = RawCredentialsExtension::default();
1723        raw.inbound_tokens.insert(
1724            TokenRole::User,
1725            RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
1726        );
1727        let extensions = Extensions {
1728            raw_credentials: Some(Arc::new(raw)),
1729            ..Default::default()
1730        };
1731
1732        let payload = crate::legacy::IdentityResolvePayload::default();
1733        let mut ctx = PluginContext::new();
1734        let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
1735        adapter
1736            .invoke(&payload, &extensions, &mut ctx)
1737            .await
1738            .expect("dispatch succeeds");
1739
1740        let task = last_task(&dir);
1741        assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN");
1742        assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
1743
1744        plugin.shutdown().await.unwrap();
1745    }
1746
1747    #[tokio::test]
1748    async fn no_capability_means_no_credential_reaches_the_worker() {
1749        if crate::testing::skip_without_python3(
1750            "no_capability_means_no_credential_reaches_the_worker",
1751        ) {
1752            return;
1753        }
1754        use cpex_core::extensions::raw_credentials::{
1755            RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1756        };
1757
1758        // Same hook, same request — this plugin simply declared nothing.
1759        let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
1760        set_mode(&dir, "allow");
1761
1762        let mut raw = RawCredentialsExtension::default();
1763        raw.inbound_tokens.insert(
1764            TokenRole::User,
1765            RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
1766        );
1767        let extensions = Extensions {
1768            raw_credentials: Some(Arc::new(raw)),
1769            ..Default::default()
1770        };
1771
1772        let payload = crate::legacy::IdentityResolvePayload::default();
1773        let mut ctx = PluginContext::new();
1774        let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
1775        adapter
1776            .invoke(&payload, &extensions, &mut ctx)
1777            .await
1778            .unwrap();
1779
1780        let task = last_task(&dir);
1781        assert!(task.get("credential").is_none(), "no credential field");
1782        assert!(
1783            !serde_json::to_string(&task)
1784                .unwrap()
1785                .contains("E2E-SECRET-TOKEN"),
1786            "no token bytes anywhere in the task the worker received"
1787        );
1788
1789        plugin.shutdown().await.unwrap();
1790    }
1791
1792    // --- dead-worker respawn ------------------------------------------------
1793
1794    fn last_pid(dir: &TempDir) -> u32 {
1795        std::fs::read_to_string(dir.path().join("last_pid"))
1796            .expect("the stub recorded its pid")
1797            .trim()
1798            .parse()
1799            .expect("pid is numeric")
1800    }
1801
1802    /// Wait until the client has observed the worker's death.
1803    ///
1804    /// `is_alive()` flips when the reader task sees EOF, which is asynchronous
1805    /// with respect to the process exiting. Polling for it keeps the respawn
1806    /// tests deterministic without a blind sleep.
1807    async fn await_worker_death(plugin: &Arc<IsolatedPythonPlugin>) {
1808        for _ in 0..200 {
1809            let alive = plugin
1810                .worker
1811                .read()
1812                .await
1813                .as_ref()
1814                .is_some_and(|w| w.is_alive());
1815            if !alive {
1816                return;
1817            }
1818            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1819        }
1820        panic!("the worker never registered as dead");
1821    }
1822
1823    #[tokio::test]
1824    async fn a_dead_worker_is_respawned_on_the_next_invocation() {
1825        // Pre-fix, one crash disabled the plugin for the life of the process:
1826        // the dead WorkerClient stayed in the slot and every later invocation
1827        // failed against it. This asserts recovery, and asserts it came from a
1828        // genuinely new process rather than the old one somehow answering.
1829        if crate::testing::skip_without_python3("a_dead_worker_is_respawned_on_the_next_invocation")
1830        {
1831            return;
1832        }
1833        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
1834        set_mode(&dir, "allow");
1835
1836        let payload = crate::legacy::ToolPreInvokePayload {
1837            name: "search".into(),
1838            ..Default::default()
1839        };
1840        let mut ctx = PluginContext::new();
1841
1842        // A healthy round trip establishes the original worker's pid.
1843        invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1844            .await
1845            .expect("the first invocation succeeds");
1846        let original_pid = last_pid(&dir);
1847
1848        // Kill it: the stub exits without replying.
1849        set_mode(&dir, "die");
1850        let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
1851        await_worker_death(&plugin).await;
1852
1853        // The next invocation must respawn and succeed rather than reporting a
1854        // permanently broken plugin.
1855        set_mode(&dir, "allow");
1856        let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1857            .await
1858            .expect("a dead worker must be replaced, not left broken");
1859        assert!(fields.continue_processing);
1860
1861        let new_pid = last_pid(&dir);
1862        assert_ne!(
1863            original_pid, new_pid,
1864            "the request must be served by a respawned process"
1865        );
1866
1867        plugin.shutdown().await.unwrap();
1868    }
1869
1870    #[tokio::test]
1871    async fn a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup() {
1872        // Fail-closed rule 2. An older worker.py does not *reject* the credential
1873        // field, it ignores it — so before the handshake, a plugin declaring
1874        // read_inbound_credentials started clean and then ran with no credential
1875        // at all, silently. That is reachable in production because two framework
1876        // builds shipped as 0.1.2 with different worker protocols.
1877        if crate::testing::skip_without_python3(
1878            "a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup",
1879        ) {
1880            return;
1881        }
1882        let (dir, plugin) =
1883            uninitialized_adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]);
1884        // A worker predating the handshake: replies "task type not supported."
1885        set_handshake_mode(&dir, "unsupported");
1886
1887        let err = plugin
1888            .initialize()
1889            .await
1890            .expect_err("a worker that would drop the credential must not be installed");
1891
1892        let message = err.to_string();
1893        assert!(
1894            message.contains("does not implement credential"),
1895            "the failure must name the missing feature: {message}"
1896        );
1897        assert!(
1898            message.contains("upgrade the cpex package"),
1899            "the failure must tell an operator what to do: {message}"
1900        );
1901
1902        // And the plugin must be left with no worker rather than a half-installed
1903        // one a later invocation could still reach.
1904        assert!(
1905            plugin.worker.read().await.is_none(),
1906            "a failed handshake must not leave a worker in the slot"
1907        );
1908    }
1909
1910    #[tokio::test]
1911    async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() {
1912        // The other half of the rule: the handshake must not become a blanket
1913        // version gate. A plugin that declares no gated capability is served
1914        // correctly by an old worker, and refusing to start it would break every
1915        // existing deployment for no safety gain.
1916        if crate::testing::skip_without_python3(
1917            "a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker",
1918        ) {
1919            return;
1920        }
1921        let (dir, plugin) = uninitialized_adapter_plugin(&["tool_pre_invoke"], &[]);
1922        set_handshake_mode(&dir, "unsupported");
1923
1924        plugin
1925            .initialize()
1926            .await
1927            .expect("a plugin needing no gated field must still start on an old worker");
1928
1929        // ...and it must actually work, not merely start.
1930        let payload = crate::legacy::ToolPreInvokePayload {
1931            name: "search".into(),
1932            ..Default::default()
1933        };
1934        let mut ctx = PluginContext::new();
1935        let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
1936            .await
1937            .expect("the hook still dispatches");
1938        assert!(fields.continue_processing);
1939
1940        plugin.shutdown().await.unwrap();
1941    }
1942
1943    #[tokio::test]
1944    async fn a_worker_answering_the_handshake_with_no_features_also_fails_closed() {
1945        // A worker that knows the task but reports an empty feature set is not
1946        // the same case as one that cannot answer, and must be treated the same:
1947        // what matters is whether the feature is claimed, never whether the
1948        // worker was new enough to be asked.
1949        if crate::testing::skip_without_python3(
1950            "a_worker_answering_the_handshake_with_no_features_also_fails_closed",
1951        ) {
1952            return;
1953        }
1954        let (dir, plugin) =
1955            uninitialized_adapter_plugin(&["tool_pre_invoke"], &["read_labels", "append_labels"]);
1956        set_handshake_mode(&dir, "no_features");
1957
1958        let err = plugin
1959            .initialize()
1960            .await
1961            .expect_err("an extensions-declaring plugin needs a worker that delivers the field");
1962
1963        let message = err.to_string();
1964        assert!(
1965            message.contains("does not implement extensions"),
1966            "an extensions capability requires the extensions feature: {message}"
1967        );
1968    }
1969
1970    #[tokio::test]
1971    async fn a_respawned_worker_receives_the_same_capability_handshake() {
1972        // The security rail. A respawned worker must be gated by exactly the
1973        // capabilities the original was — never a weaker set, and never a
1974        // "recovery mode" that skips the gate. Both directions are asserted
1975        // against one respawn: the declared capability still delivers its
1976        // credential, and an undeclared slot still delivers nothing.
1977        if crate::testing::skip_without_python3(
1978            "a_respawned_worker_receives_the_same_capability_handshake",
1979        ) {
1980            return;
1981        }
1982        use cpex_core::extensions::raw_credentials::{
1983            RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
1984        };
1985
1986        let (dir, plugin) =
1987            adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
1988        set_mode(&dir, "allow");
1989
1990        let mut raw = RawCredentialsExtension::default();
1991        raw.inbound_tokens.insert(
1992            TokenRole::User,
1993            RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt),
1994        );
1995        let extensions = Extensions {
1996            raw_credentials: Some(Arc::new(raw)),
1997            ..Default::default()
1998        };
1999
2000        let payload = crate::legacy::IdentityResolvePayload::default();
2001        let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
2002
2003        let mut ctx = PluginContext::new();
2004        adapter
2005            .invoke(&payload, &extensions, &mut ctx)
2006            .await
2007            .expect("the first dispatch succeeds");
2008        let original_pid = last_pid(&dir);
2009        assert_eq!(
2010            last_task(&dir)["credential"]["inbound"]["token"],
2011            "RESPAWN-SECRET"
2012        );
2013
2014        set_mode(&dir, "die");
2015        let mut ctx = PluginContext::new();
2016        let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2017        await_worker_death(&plugin).await;
2018
2019        // Same hook, same declared capability, new process.
2020        set_mode(&dir, "allow");
2021        let mut ctx = PluginContext::new();
2022        adapter
2023            .invoke(&payload, &extensions, &mut ctx)
2024            .await
2025            .expect("dispatch succeeds against the respawned worker");
2026
2027        assert_ne!(original_pid, last_pid(&dir), "a new process served it");
2028        let task = last_task(&dir);
2029        assert_eq!(
2030            task["credential"]["inbound"]["token"], "RESPAWN-SECRET",
2031            "the respawned worker must get the same credential handshake"
2032        );
2033        assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
2034
2035        plugin.shutdown().await.unwrap();
2036    }
2037
2038    #[tokio::test]
2039    async fn a_respawned_worker_gets_no_credential_it_did_not_declare() {
2040        // The fail-closed half: a plugin with no declared capability must not
2041        // pick one up by virtue of having been respawned.
2042        if crate::testing::skip_without_python3(
2043            "a_respawned_worker_gets_no_credential_it_did_not_declare",
2044        ) {
2045            return;
2046        }
2047        use cpex_core::extensions::raw_credentials::{
2048            RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
2049        };
2050
2051        let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
2052        set_mode(&dir, "allow");
2053
2054        let mut raw = RawCredentialsExtension::default();
2055        raw.inbound_tokens.insert(
2056            TokenRole::User,
2057            RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt),
2058        );
2059        let extensions = Extensions {
2060            raw_credentials: Some(Arc::new(raw)),
2061            ..Default::default()
2062        };
2063
2064        let payload = crate::legacy::IdentityResolvePayload::default();
2065        let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
2066
2067        let mut ctx = PluginContext::new();
2068        adapter
2069            .invoke(&payload, &extensions, &mut ctx)
2070            .await
2071            .unwrap();
2072
2073        set_mode(&dir, "die");
2074        let mut ctx = PluginContext::new();
2075        let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
2076        await_worker_death(&plugin).await;
2077
2078        set_mode(&dir, "allow");
2079        let mut ctx = PluginContext::new();
2080        adapter
2081            .invoke(&payload, &extensions, &mut ctx)
2082            .await
2083            .expect("dispatch succeeds after respawn");
2084
2085        let task = last_task(&dir);
2086        assert!(
2087            task.get("credential").is_none(),
2088            "respawn must not widen the capability gate"
2089        );
2090        assert!(
2091            !serde_json::to_string(&task)
2092                .unwrap()
2093                .contains("MUST-NOT-LEAK"),
2094            "no token bytes may reach a worker that declared nothing"
2095        );
2096
2097        plugin.shutdown().await.unwrap();
2098    }
2099
2100    #[tokio::test]
2101    async fn a_worker_that_will_not_stay_up_gives_up_with_a_typed_error() {
2102        // The anti-loop rail. A worker that dies on every launch must surface a
2103        // typed error after a bounded number of attempts rather than retrying
2104        // forever. Pointing the override at a script that always exits makes
2105        // every respawn produce a corpse.
2106        if crate::testing::skip_without_python3(
2107            "a_worker_that_will_not_stay_up_gives_up_with_a_typed_error",
2108        ) {
2109            return;
2110        }
2111        let dir = TempDir::new();
2112        let script = dir.path().join("always_dies.py");
2113        // Exits at import, before reading a task — so every spawn dies.
2114        std::fs::write(
2115            &script,
2116            "import sys\nprint('fatal: dependency missing', file=sys.stderr, flush=True)\nsys.exit(1)\n",
2117        )
2118        .unwrap();
2119
2120        let config = PluginConfig {
2121            name: "crash-loop".into(),
2122            kind: crate::factory::KIND.into(),
2123            hooks: vec!["tool_pre_invoke".into()],
2124            config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2125            ..Default::default()
2126        };
2127        let plugin = Arc::new(
2128            IsolatedPythonPlugin::from_config(&config)
2129                .expect("config parses")
2130                .with_worker_override(script),
2131        );
2132        // The process *starts* (then dies), so initialize succeeds — this is
2133        // exactly the shape a bad dependency pin produces.
2134        plugin.initialize().await.expect("the process launches");
2135
2136        let payload = crate::legacy::ToolPreInvokePayload {
2137            name: "search".into(),
2138            ..Default::default()
2139        };
2140
2141        // Drive invocations until one reports the budget is spent. The bound is
2142        // what is under test: without it this loop never terminates.
2143        let started = std::time::Instant::now();
2144        let mut gave_up = None;
2145        for _ in 0..(MAX_RESPAWN_ATTEMPTS + 4) {
2146            let mut ctx = PluginContext::new();
2147            let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
2148                panic!("a worker that always dies cannot serve a request");
2149            };
2150            let message = err.to_string();
2151            if message.contains("could not be respawned") {
2152                gave_up = Some(message);
2153                break;
2154            }
2155        }
2156
2157        let message = gave_up
2158            .expect("a permanently failing worker must give up with an error, not retry forever");
2159        assert!(
2160            message.contains(&MAX_RESPAWN_ATTEMPTS.to_string()),
2161            "the error should name the attempt bound: {message}"
2162        );
2163
2164        // The backoff is bounded too — giving up must not take minutes.
2165        assert!(
2166            started.elapsed() < std::time::Duration::from_secs(20),
2167            "giving up took too long: {:?}",
2168            started.elapsed()
2169        );
2170
2171        plugin.shutdown().await.unwrap();
2172    }
2173
2174    #[tokio::test]
2175    async fn respawn_is_not_attempted_before_initialize() {
2176        // Respawn must not paper over a lifecycle bug by starting a worker the
2177        // manager never asked for: an uninitialized plugin still errors.
2178        let config = PluginConfig {
2179            name: "uninitialized".into(),
2180            kind: crate::factory::KIND.into(),
2181            hooks: vec!["tool_pre_invoke".into()],
2182            config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
2183            ..Default::default()
2184        };
2185        let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
2186
2187        let Err(err) = plugin.worker().await else {
2188            panic!("a plugin with no worker must not silently spawn one");
2189        };
2190        let message = err.to_string();
2191        assert!(
2192            message.contains("initialize()"),
2193            "the error should name the lifecycle gap: {message}"
2194        );
2195        assert!(
2196            plugin.worker.read().await.is_none(),
2197            "no worker was started"
2198        );
2199    }
2200
2201    #[tokio::test]
2202    async fn concurrent_invocations_share_one_respawn() {
2203        // Several hooks of one plugin can be in flight at once (the executor
2204        // dispatches parallel-phase plugins concurrently). They must not each
2205        // spend respawn budget on the same death and start competing workers.
2206        if crate::testing::skip_without_python3("concurrent_invocations_share_one_respawn") {
2207            return;
2208        }
2209        let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
2210        set_mode(&dir, "allow");
2211
2212        let payload = crate::legacy::ToolPreInvokePayload {
2213            name: "search".into(),
2214            ..Default::default()
2215        };
2216        let mut ctx = PluginContext::new();
2217        invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2218            .await
2219            .unwrap();
2220
2221        set_mode(&dir, "die");
2222        let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2223        await_worker_death(&plugin).await;
2224        set_mode(&dir, "allow");
2225
2226        // Four concurrent invocations against one dead worker.
2227        let mut handles = Vec::new();
2228        for _ in 0..4 {
2229            let plugin = Arc::clone(&plugin);
2230            handles.push(tokio::spawn(async move {
2231                let payload = crate::legacy::ToolPreInvokePayload {
2232                    name: "search".into(),
2233                    ..Default::default()
2234                };
2235                let mut ctx = PluginContext::new();
2236                invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2237                    .await
2238                    .map(|f| f.continue_processing)
2239            }));
2240        }
2241
2242        for handle in handles {
2243            assert!(handle.await.unwrap().expect("each invocation recovers"));
2244        }
2245
2246        // One respawn served all four, so the budget is intact — a second
2247        // independent death must still be recoverable.
2248        set_mode(&dir, "die");
2249        let mut ctx = PluginContext::new();
2250        let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
2251        await_worker_death(&plugin).await;
2252        set_mode(&dir, "allow");
2253
2254        let mut ctx = PluginContext::new();
2255        invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
2256            .await
2257            .expect("budget was not exhausted by the concurrent respawn");
2258
2259        plugin.shutdown().await.unwrap();
2260    }
2261
2262    #[test]
2263    fn the_respawn_budget_is_bounded_then_resets_with_time() {
2264        // Unit-level pin on the accounting, so the bound and the reset window
2265        // are covered without spawning processes.
2266        let mut state = RespawnState::new();
2267        for expected in 1..=MAX_RESPAWN_ATTEMPTS {
2268            assert_eq!(state.try_reserve(), Some(expected));
2269        }
2270        assert_eq!(
2271            state.try_reserve(),
2272            None,
2273            "the budget must be bounded, not unlimited"
2274        );
2275
2276        // A successful invocation refills it.
2277        state.record_success();
2278        assert_eq!(state.try_reserve(), Some(1));
2279
2280        // ...and so does the passage of the reset window, so a plugin is not
2281        // poisoned for the life of the process once its cause is fixed.
2282        let mut state = RespawnState::new();
2283        while state.try_reserve().is_some() {}
2284        state.window_started = Some(
2285            std::time::Instant::now()
2286                - std::time::Duration::from_secs(RESPAWN_BUDGET_RESET_SECS + 1),
2287        );
2288        assert_eq!(
2289            state.try_reserve(),
2290            Some(1),
2291            "an elapsed window must restore the budget"
2292        );
2293    }
2294
2295    #[test]
2296    fn unknown_config_keys_are_tolerated() {
2297        // Plugin `config:` blocks are shared with the Python side, which reads
2298        // keys this host does not model. Rejecting them would break configs
2299        // that work under the Python CLI.
2300        let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
2301            "class_name": "my_pkg.Minimal",
2302            "some_python_only_setting": true,
2303        })))
2304        .expect("extra keys belong to the Python plugin, not this host");
2305        assert_eq!(cfg.class_name, "my_pkg.Minimal");
2306    }
2307}