use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use cpex_hosts_python::factory::{IsolatedVenvFactory, KIND};
use cpex_hosts_python::legacy::{IdentityResolvePayload, TokenDelegatePayload};
use cpex_hosts_python::plugin::IsolatedPythonPlugin;
use cpex_hosts_python::testing::{skip, skip_without_python3};
use ppe::praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall};
use ppe::praxis_policy_core::delegation::HOOK_TOKEN_DELEGATE;
use ppe::praxis_policy_core::engine::{PolicyEngine, PolicyEngineConfig};
use ppe::praxis_policy_core::extensions::{
AgentExtension, Extensions, HttpExtension, RequestExtension, SecurityExtension,
};
use ppe::praxis_policy_core::identity::HOOK_IDENTITY_RESOLVE;
const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke";
const TOOL_PRE_INVOKE_MARKER: &str = "tool_pre_invoke";
const IDENTITY_RESOLVE_MARKER: &str = "identity_resolve";
const TOKEN_DELEGATE_MARKER: &str = "token_delegate";
const PLUGIN_NAME: &str = "cpex-test-plugin";
const IDENTITY_RAW_TOKEN_MARKER: &str = "identity_resolve_raw_token";
const TOKEN_DELEGATE_BEARER_MARKER: &str = "token_delegate_bearer_token";
const INBOUND_LABEL: &str = "CONFIG-E2E-LABEL-4f18";
const SENSITIVE_HEADER_VALUE: &str = "Bearer config-e2e-must-not-travel";
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("the manifest dir has a crates/<crate> ancestry")
.to_path_buf()
}
fn migrate_installer_config(yaml: &str) -> String {
let mut doc: serde_yaml::Value = serde_yaml::from_str(yaml).expect("config.yaml is YAML");
let root = doc.as_mapping_mut().expect("config.yaml is a mapping");
root.remove("plugin_dirs");
if !root.contains_key("engine_settings") {
let mut settings = serde_yaml::Mapping::new();
settings.insert("dispatch".into(), "hooks".into());
root.insert("engine_settings".into(), settings.into());
}
if let Some(plugins) = root
.get_mut("plugins")
.and_then(serde_yaml::Value::as_sequence_mut)
{
for plugin in plugins {
let Some(hooks) = plugin
.get_mut("hooks")
.and_then(serde_yaml::Value::as_sequence_mut)
else {
continue;
};
for hook in hooks {
let renamed = match hook.as_str() {
Some("tool_pre_invoke") => TOOL_PRE_INVOKE,
Some("identity_resolve") => HOOK_IDENTITY_RESOLVE,
Some("token_delegate") => HOOK_TOKEN_DELEGATE,
_ => continue,
};
*hook = serde_yaml::Value::from(renamed);
}
}
}
serde_yaml::to_string(&doc).expect("the migrated config serializes")
}
fn load_installed_config(config_path: &Path) -> Arc<PolicyEngine> {
let yaml = std::fs::read_to_string(config_path).expect("plugins/config.yaml is readable");
let config = ppe::praxis_policy_core::config::parse_config(&migrate_installer_config(&yaml))
.expect("the migrated plugins/config.yaml parses");
let manager = Arc::new(PolicyEngine::new(PolicyEngineConfig::default()));
manager.register_factory(KIND, Box::new(IsolatedVenvFactory));
manager
.load_config(config)
.expect("plugins/config.yaml loads with the isolated_venv factory registered");
manager
}
fn require_installed_plugin(test_name: &str) -> Option<PathBuf> {
if skip_without_python3(test_name) {
return None;
}
let root = repo_root();
let config_path = root.join("plugins").join("config.yaml");
if !config_path.is_file() {
skip(
test_name,
&format!(
"no {} — install the plugin first (see the setup comment)",
config_path.display()
),
);
return None;
}
let venv = root
.join("plugins")
.join("cpex_test_plugin")
.join(".venv")
.join("bin")
.join("python");
if !venv.is_file() {
skip(
test_name,
&format!(
"no venv at {} — run `cpex plugin --type test-pypi install \
\"cpex-test-plugin@>=0.2.0\"` first",
venv.display()
),
);
return None;
}
Some(config_path)
}
fn clear_marker(root: &Path, name: &str) -> PathBuf {
let marker = root.join(format!(".{name}"));
let _ = std::fs::remove_file(&marker);
marker
}
fn assert_marker_written(marker: &Path, description: &str) {
let touched = marker.is_file();
let _ = std::fs::remove_file(marker);
assert!(
touched,
"expected TestPlugin.{description} to create {} — the hook body did not run",
marker.display()
);
}
fn assert_allowed(result: &ppe::praxis_policy_core::executor::PipelineResult, hook_name: &str) {
assert!(
result.errors.is_empty(),
"the pipeline recorded plugin errors on {hook_name}: {:?}",
result.errors
);
assert!(
result.continue_processing,
"TestPlugin.{hook_name} returns continue_processing=True unconditionally: violation={:?}",
result.violation
);
assert!(
result.violation.is_none(),
"an allow carries no violation on {hook_name}: {:?}",
result.violation
);
}
fn inbound_extensions() -> Extensions {
let mut security = SecurityExtension::default();
security.add_label(INBOUND_LABEL);
security.classification = Some("internal".to_string());
security.auth_method = Some("jwt".to_string());
let mut http = HttpExtension::default();
http.set_request_header("Authorization", SENSITIVE_HEADER_VALUE);
http.set_request_header("X-Request-Id", "req-config-e2e-1");
http.method = Some("POST".to_string());
http.path = Some("/rpc/tools/invoke".to_string());
Extensions {
request: Some(Arc::new(RequestExtension {
environment: Some("test".to_string()),
request_id: Some("req-config-e2e-1".to_string()),
trace_id: Some("trace-config-e2e-1".to_string()),
..Default::default()
})),
agent: Some(Arc::new(AgentExtension {
session_id: Some("session-config-e2e".to_string()),
agent_id: Some("agent-config-e2e".to_string()),
turn: Some(3),
..Default::default()
})),
security: Some(Arc::new(security)),
http: Some(Arc::new(http)),
custom: Some(Arc::new(HashMap::from([(
"config_e2e".to_string(),
serde_json::json!({"source": "config_e2e.rs"}),
)]))),
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires cpex-test-plugin installed under plugins/ with a built venv"]
async fn config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin() {
let test_name = "config_yaml_dispatches_tool_pre_invoke_to_the_installed_plugin";
let Some(config_path) = require_installed_plugin(test_name) else {
return;
};
let root = repo_root();
std::env::set_current_dir(&root).expect("cd to the repo root");
let marker = clear_marker(&root, TOOL_PRE_INVOKE_MARKER);
let manager = load_installed_config(&config_path);
assert!(
manager.has_hooks_for(TOOL_PRE_INVOKE),
"no plugin registered on {} — check that config.yaml still declares \
kind: {KIND} and that hook",
TOOL_PRE_INVOKE
);
assert!(
manager.plugin_names().iter().any(|n| n == PLUGIN_NAME),
"expected '{PLUGIN_NAME}' among the loaded plugins, got {:?}",
manager.plugin_names()
);
manager
.initialize()
.await
.expect("the venv resolves and the worker starts");
let payload = MessagePayload {
message: Message::with_content(
Role::Assistant,
vec![ContentPart::ToolCall {
content: ToolCall {
tool_call_id: "call-config-e2e-1".to_string(),
name: "test_tool".to_string(),
arguments: HashMap::from([(
"query".to_string(),
serde_json::json!("hello world"),
)]),
namespace: None,
},
}],
),
};
let inbound = inbound_extensions();
let (result, _background) = manager
.invoke_by_name(TOOL_PRE_INVOKE, Box::new(payload), inbound.clone(), None)
.await;
manager.shutdown().await;
assert_allowed(&result, TOOL_PRE_INVOKE_MARKER);
assert_marker_written(&marker, TOOL_PRE_INVOKE_MARKER);
let returned = result
.modified_extensions
.as_ref()
.expect("an allowed pipeline always carries its final extensions view");
assert!(
returned.agent.is_some() && returned.request.is_some() && returned.http.is_some(),
"the pipeline's own view keeps the slots it started with; per-plugin \
filtering must not mutate it"
);
assert!(
returned
.security
.as_ref()
.is_some_and(|s| s.has_label(INBOUND_LABEL)),
"a plugin that never received `security.labels` cannot have dropped the \
pipeline's label"
);
assert_eq!(
returned.custom.as_deref(),
inbound.custom.as_deref(),
"no plugin wrote `custom`, so it must survive the pipeline byte-for-byte"
);
let filtered =
ppe::praxis_policy_core::extensions::filter_extensions(&inbound, &Default::default());
assert!(
filtered.agent.is_none(),
"`agent` is capability-gated and this config declares none, so it must \
not reach the plugin"
);
assert!(
filtered.http.is_none(),
"`http` is capability-gated and this config declares none, so it must \
not reach the plugin"
);
assert!(
!filtered
.security
.as_ref()
.expect("the security slot survives; its gated sub-fields do not")
.has_label(INBOUND_LABEL),
"`security.labels` is gated under `read_labels`, which this config does \
not declare"
);
assert!(
filtered.request.is_some(),
"`request` is unrestricted — stripping it would silently starve every \
plugin of request context"
);
let mut task = serde_json::json!({"task_type": "load_and_run_hook"});
cpex_hosts_python::extensions::attach_extensions(&mut task, &inbound)
.expect("the inbound view serializes");
let wire = serde_json::to_string(&task).expect("the task serializes");
assert!(
!wire.contains(SENSITIVE_HEADER_VALUE),
"no credential header value may appear anywhere in the task JSON: {wire}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires cpex-test-plugin installed under plugins/ with a built venv"]
async fn config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin() {
let test_name =
"config_yaml_dispatches_identity_resolve_and_token_delegate_to_the_installed_plugin";
let Some(config_path) = require_installed_plugin(test_name) else {
return;
};
let root = repo_root();
std::env::set_current_dir(&root).expect("cd to the repo root");
let identity_marker = clear_marker(&root, IDENTITY_RESOLVE_MARKER);
let delegate_marker = clear_marker(&root, TOKEN_DELEGATE_MARKER);
let identity_field_marker = clear_marker(&root, IDENTITY_RAW_TOKEN_MARKER);
let delegate_field_marker = clear_marker(&root, TOKEN_DELEGATE_BEARER_MARKER);
let manager = load_installed_config(&config_path);
for hook in [HOOK_IDENTITY_RESOLVE, HOOK_TOKEN_DELEGATE] {
assert!(
manager.has_hooks_for(hook),
"no plugin registered on {hook} — check that config.yaml still lists it under \
`hooks:` for a kind: {KIND} plugin"
);
}
assert!(
manager.plugin_names().iter().any(|n| n == PLUGIN_NAME),
"expected '{PLUGIN_NAME}' among the loaded plugins, got {:?}",
manager.plugin_names()
);
manager
.initialize()
.await
.expect("the venv resolves and the worker starts");
let identity_payload = IdentityResolvePayload {
raw_token: "<redacted>".to_string(),
source: "bearer".to_string(),
headers: HashMap::from([("Authorization".to_string(), "<redacted>".to_string())]),
client_host: Some("10.0.0.1".to_string()),
client_port: Some(8443),
};
let (identity_result, _background) = manager
.invoke_by_name(
HOOK_IDENTITY_RESOLVE,
Box::new(identity_payload),
Extensions::default(),
None,
)
.await;
let delegate_payload = TokenDelegatePayload {
target_name: "billing-api".to_string(),
target_audience: Some("https://billing.internal".to_string()),
required_permissions: vec!["invoices:read".to_string()],
bearer_token: Some("mock-bearer-token".to_string()),
..Default::default()
};
let (delegate_result, _background) = manager
.invoke_by_name(
HOOK_TOKEN_DELEGATE,
Box::new(delegate_payload),
Extensions::default(),
None,
)
.await;
manager.shutdown().await;
assert_allowed(&identity_result, HOOK_IDENTITY_RESOLVE);
assert_allowed(&delegate_result, HOOK_TOKEN_DELEGATE);
assert_marker_written(&identity_marker, IDENTITY_RESOLVE_MARKER);
assert_marker_written(&delegate_marker, TOKEN_DELEGATE_MARKER);
assert_marker_written(
&identity_field_marker,
"identity_resolve (non-empty raw_token)",
);
assert_marker_written(
&delegate_field_marker,
"token_delegate (non-empty bearer_token)",
);
}
#[test]
fn an_installer_generated_config_resolves_plugins_at_the_project_root() {
let yaml = r#"
plugin_dirs:
- /top/level/ignored
plugins:
- name: cpex-test-plugin
kind: isolated_venv
hooks: [tool_pre_invoke, identity_resolve, token_delegate]
version: 0.2.1
config:
class_name: cpex_test_plugin.plugin.TestPlugin
requirements_file: requirements.txt
plugin_dirs: ["/per/plugin/ignored"]
"#;
let err = ppe::praxis_policy_core::config::parse_config(yaml)
.expect_err("a top-level plugin_dirs key must fail the load");
assert!(err.to_string().contains("plugin_dirs"), "{err}");
let config = ppe::praxis_policy_core::config::parse_config(&migrate_installer_config(yaml))
.expect("the migrated config parses");
assert_eq!(
config.plugins[0].hooks,
vec![
TOOL_PRE_INVOKE.to_string(),
HOOK_IDENTITY_RESOLVE.to_string(),
HOOK_TOKEN_DELEGATE.to_string()
],
"the migrated names are PPE hooks, so no host hook registration is needed"
);
let plugin = IsolatedPythonPlugin::from_config(&config.plugins[0])
.expect("an ignored plugin_dirs key must not fail the load");
let expected = std::env::current_dir()
.unwrap()
.join(cpex_hosts_python::DEFAULT_PLUGIN_DIR)
.display()
.to_string();
assert_eq!(
plugin.plugin_dirs(),
[expected],
"the host must resolve <project root>/plugins, ignoring the per-plugin key"
);
}