Skip to main content

cpex_hosts_python/legacy/
payloads.rs

1// Location: ./crates/cpex-hosts-python/src/legacy/payloads.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Typed payloads for the eight legacy (non-CMF) hooks.
7//
8// Each struct's serialized JSON must match the Pydantic model `worker.py`
9// reconstructs via `json_to_payload`, field for field — a name mismatch
10// surfaces as a Pydantic validation error inside the worker, at invoke time,
11// far from its cause. The Python model each one mirrors is named in its doc
12// comment; the field-shape tests in this module are the guard.
13//
14// # Redaction
15//
16// `IdentityResolvePayload` and `TokenDelegatePayload` carry token-adjacent
17// fields, so both get a hand-written `Debug` that prints a placeholder instead
18// of the value. A derive would put credential material into any `{:?}` — a
19// tracing call, an `unwrap()` panic message, an assertion failure. The
20// equivalent gap on the pre-existing cpex-core types is deferred follow-up
21// work; these new types do not reintroduce it.
22
23use std::collections::HashMap;
24
25use serde::{Deserialize, Serialize};
26
27/// Placeholder printed in place of any token-adjacent value.
28const REDACTED: &str = "<redacted>";
29
30/// `tool_pre_invoke` — mirrors `ToolPreInvokePayload`.
31///
32/// `headers` mirrors `HttpHeaderPayload`, which serializes as a plain string
33/// map.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct ToolPreInvokePayload {
36    pub name: String,
37
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub args: Option<HashMap<String, serde_json::Value>>,
40
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub headers: Option<HashMap<String, String>>,
43}
44cpex_core::impl_plugin_payload!(ToolPreInvokePayload);
45
46/// `tool_post_invoke` — mirrors `ToolPostInvokePayload`.
47///
48/// `result` is `Any` on the Python side, so it stays an untyped JSON value.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ToolPostInvokePayload {
51    pub name: String,
52
53    #[serde(default)]
54    pub result: serde_json::Value,
55}
56cpex_core::impl_plugin_payload!(ToolPostInvokePayload);
57
58/// `prompt_pre_fetch` — mirrors `PromptPrehookPayload`.
59///
60/// Note `args` is `dict[str, str]` here, not `dict[str, Any]` as on the tool
61/// payload.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct PromptPreFetchPayload {
64    pub prompt_id: String,
65
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub args: Option<HashMap<String, String>>,
68}
69cpex_core::impl_plugin_payload!(PromptPreFetchPayload);
70
71/// `prompt_post_fetch` — mirrors `PromptPosthookPayload`.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct PromptPostFetchPayload {
74    pub prompt_id: String,
75
76    #[serde(default)]
77    pub result: serde_json::Value,
78}
79cpex_core::impl_plugin_payload!(PromptPostFetchPayload);
80
81/// `resource_pre_fetch` — mirrors `ResourcePreFetchPayload`.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct ResourcePreFetchPayload {
84    pub uri: String,
85
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub metadata: Option<HashMap<String, serde_json::Value>>,
88}
89cpex_core::impl_plugin_payload!(ResourcePreFetchPayload);
90
91/// `resource_post_fetch` — mirrors `ResourcePostFetchPayload`.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct ResourcePostFetchPayload {
94    pub uri: String,
95
96    #[serde(default)]
97    pub content: serde_json::Value,
98}
99cpex_core::impl_plugin_payload!(ResourcePostFetchPayload);
100
101/// `identity_resolve` — mirrors `IdentityPayload`.
102///
103/// `raw_token` is `SecretStr` on the Python side and redacts on serialization,
104/// so the value that travels in *this* field is a placeholder, never the real
105/// token. The plaintext rides the separate capability-gated `credential`
106/// object on the task (see the `credentials` module) and the worker folds it
107/// back onto the payload before calling the plugin.
108///
109/// `Debug` is hand-written — see the module docs.
110#[derive(Clone, Default, Serialize, Deserialize)]
111pub struct IdentityResolvePayload {
112    /// Redacted on the wire. Populated from the `credential` object by the
113    /// worker, not from this field.
114    #[serde(default)]
115    pub raw_token: String,
116
117    /// How the credential was presented — `bearer`, `custom`, and so on.
118    #[serde(default)]
119    pub source: String,
120
121    /// Headers the credential arrived in. Not a `SecretStr` on the Python
122    /// side, so the worker scrubs the plaintext out of these values before a
123    /// plugin can echo them back.
124    #[serde(default)]
125    pub headers: HashMap<String, String>,
126
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub client_host: Option<String>,
129
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub client_port: Option<u16>,
132}
133cpex_core::impl_plugin_payload!(IdentityResolvePayload);
134
135impl std::fmt::Debug for IdentityResolvePayload {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        // `raw_token` and every header value are redacted: a header can carry
138        // the credential ("Authorization: Bearer <token>"), so printing keys
139        // alone is the safe maximum.
140        f.debug_struct("IdentityResolvePayload")
141            .field("raw_token", &REDACTED)
142            .field("source", &self.source)
143            .field("header_names", &self.headers.keys().collect::<Vec<_>>())
144            .field("client_host", &self.client_host)
145            .field("client_port", &self.client_port)
146            .finish()
147    }
148}
149
150/// Scope-attenuation config — mirrors `AttenuationConfig`.
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152pub struct AttenuationConfig {
153    #[serde(default)]
154    pub capabilities: Vec<String>,
155
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub resource_template: Option<String>,
158
159    #[serde(default)]
160    pub actions: Vec<String>,
161
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub ttl_seconds: Option<i64>,
164}
165
166/// `token_delegate` — mirrors `DelegationPayload`.
167///
168/// `bearer_token` is `SecretStr | None` on the Python side and redacts on
169/// serialization, so this field carries a placeholder. The plaintext travels
170/// via the `credential` object.
171///
172/// `Debug` is hand-written — see the module docs.
173#[derive(Clone, Default, Serialize, Deserialize)]
174pub struct TokenDelegatePayload {
175    pub target_name: String,
176
177    #[serde(default = "default_target_type")]
178    pub target_type: String,
179
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub target_audience: Option<String>,
182
183    #[serde(default)]
184    pub required_permissions: Vec<String>,
185
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub trust_domain: Option<String>,
188
189    #[serde(default = "default_auth_enforced_by")]
190    pub auth_enforced_by: String,
191
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub route_attenuation: Option<AttenuationConfig>,
194
195    /// Redacted on the wire; the plaintext comes from the `credential` object.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub bearer_token: Option<String>,
198}
199cpex_core::impl_plugin_payload!(TokenDelegatePayload);
200
201fn default_target_type() -> String {
202    "tool".to_string()
203}
204
205fn default_auth_enforced_by() -> String {
206    "caller".to_string()
207}
208
209impl std::fmt::Debug for TokenDelegatePayload {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("TokenDelegatePayload")
212            .field("target_name", &self.target_name)
213            .field("target_type", &self.target_type)
214            .field("target_audience", &self.target_audience)
215            .field("required_permissions", &self.required_permissions)
216            .field("trust_domain", &self.trust_domain)
217            .field("auth_enforced_by", &self.auth_enforced_by)
218            .field("route_attenuation", &self.route_attenuation)
219            // Presence is useful for diagnosis; the value never is.
220            .field(
221                "bearer_token",
222                &self.bearer_token.as_ref().map(|_| REDACTED),
223            )
224            .finish()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// Serialize and return the JSON object, so field names can be asserted
233    /// against the Pydantic models `worker.py` reconstructs.
234    fn json_of<T: Serialize>(value: &T) -> serde_json::Value {
235        serde_json::to_value(value).expect("payload serializes")
236    }
237
238    #[test]
239    fn tool_pre_invoke_matches_the_python_field_shape() {
240        let payload = ToolPreInvokePayload {
241            name: "search".into(),
242            args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
243            headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
244        };
245        let json = json_of(&payload);
246
247        assert_eq!(json["name"], "search");
248        assert_eq!(json["args"]["q"], "rust");
249        assert_eq!(json["headers"]["X-Tenant"], "acme");
250    }
251
252    #[test]
253    fn tool_pre_invoke_omits_absent_optionals() {
254        // Pydantic defaults `args` to {} and `headers` to None. Emitting
255        // explicit nulls would override those defaults on the Python side, so
256        // absent fields must stay absent.
257        let json = json_of(&ToolPreInvokePayload {
258            name: "search".into(),
259            ..Default::default()
260        });
261        assert_eq!(
262            json.as_object().unwrap().len(),
263            1,
264            "only `name` should be present: {json}"
265        );
266    }
267
268    #[test]
269    fn tool_pre_invoke_round_trips() {
270        let payload = ToolPreInvokePayload {
271            name: "search".into(),
272            args: Some(HashMap::from([("q".into(), serde_json::json!(7))])),
273            headers: None,
274        };
275        let restored: ToolPreInvokePayload = serde_json::from_value(json_of(&payload)).unwrap();
276        assert_eq!(restored.name, "search");
277        assert_eq!(restored.args.unwrap()["q"], 7);
278    }
279
280    #[test]
281    fn tool_post_invoke_matches_the_python_field_shape() {
282        let json = json_of(&ToolPostInvokePayload {
283            name: "search".into(),
284            result: serde_json::json!({"hits": 3}),
285        });
286        assert_eq!(json["name"], "search");
287        assert_eq!(json["result"]["hits"], 3);
288    }
289
290    #[test]
291    fn prompt_payloads_use_prompt_id_not_name() {
292        // The prompt hooks key on `prompt_id`; using `name` here would fail
293        // Pydantic validation inside the worker.
294        let pre = json_of(&PromptPreFetchPayload {
295            prompt_id: "greeting".into(),
296            args: Some(HashMap::from([("lang".into(), "en".into())])),
297        });
298        assert_eq!(pre["prompt_id"], "greeting");
299        assert_eq!(pre["args"]["lang"], "en");
300        assert!(pre.get("name").is_none());
301
302        let post = json_of(&PromptPostFetchPayload {
303            prompt_id: "greeting".into(),
304            result: serde_json::json!({"messages": []}),
305        });
306        assert_eq!(post["prompt_id"], "greeting");
307        assert!(post["result"]["messages"].is_array());
308    }
309
310    #[test]
311    fn prompt_pre_fetch_args_are_strings() {
312        // dict[str, str] on the Python side, so a non-string value must not
313        // deserialize into it.
314        let parsed: Result<PromptPreFetchPayload, _> =
315            serde_json::from_value(serde_json::json!({ "prompt_id": "p", "args": {"n": 5} }));
316        assert!(
317            parsed.is_err(),
318            "prompt args are dict[str, str], not dict[str, Any]"
319        );
320    }
321
322    #[test]
323    fn resource_payloads_key_on_uri() {
324        let pre = json_of(&ResourcePreFetchPayload {
325            uri: "file:///tmp/x".into(),
326            metadata: Some(HashMap::from([("etag".into(), serde_json::json!("abc"))])),
327        });
328        assert_eq!(pre["uri"], "file:///tmp/x");
329        assert_eq!(pre["metadata"]["etag"], "abc");
330
331        let post = json_of(&ResourcePostFetchPayload {
332            uri: "file:///tmp/x".into(),
333            content: serde_json::json!("hello"),
334        });
335        assert_eq!(post["content"], "hello");
336    }
337
338    #[test]
339    fn identity_resolve_matches_the_python_field_shape() {
340        let json = json_of(&IdentityResolvePayload {
341            raw_token: "**********".into(),
342            source: "bearer".into(),
343            headers: HashMap::from([("Authorization".into(), "**********".into())]),
344            client_host: Some("10.0.0.1".into()),
345            client_port: Some(443),
346        });
347
348        assert_eq!(json["source"], "bearer");
349        assert_eq!(json["client_host"], "10.0.0.1");
350        assert_eq!(json["client_port"], 443);
351        assert!(
352            json.get("raw_token").is_some(),
353            "the field exists; its value is a placeholder"
354        );
355    }
356
357    #[test]
358    fn token_delegate_matches_the_python_field_shape_and_defaults() {
359        let parsed: TokenDelegatePayload =
360            serde_json::from_value(serde_json::json!({ "target_name": "billing-api" })).unwrap();
361
362        // Pydantic defaults these two; the host must agree or a minimal
363        // payload would deserialize with empty strings.
364        assert_eq!(parsed.target_type, "tool");
365        assert_eq!(parsed.auth_enforced_by, "caller");
366        assert!(parsed.required_permissions.is_empty());
367        assert!(parsed.bearer_token.is_none());
368    }
369
370    #[test]
371    fn token_delegate_carries_attenuation_config() {
372        let json = json_of(&TokenDelegatePayload {
373            target_name: "billing-api".into(),
374            route_attenuation: Some(AttenuationConfig {
375                capabilities: vec!["read".into()],
376                resource_template: Some("/v1/*".into()),
377                actions: vec!["GET".into()],
378                ttl_seconds: Some(300),
379            }),
380            ..Default::default()
381        });
382
383        assert_eq!(json["route_attenuation"]["capabilities"][0], "read");
384        assert_eq!(json["route_attenuation"]["ttl_seconds"], 300);
385    }
386
387    // --- redaction ----------------------------------------------------------
388
389    #[test]
390    fn identity_resolve_debug_hides_token_and_header_values() {
391        let payload = IdentityResolvePayload {
392            raw_token: "eyJhbGciOiJSUzI1NiJ9.SECRET-TOKEN-BYTES".into(),
393            source: "bearer".into(),
394            headers: HashMap::from([("Authorization".into(), "Bearer SECRET-TOKEN-BYTES".into())]),
395            client_host: None,
396            client_port: None,
397        };
398
399        let debug = format!("{payload:?}");
400        assert!(
401            !debug.contains("SECRET-TOKEN-BYTES"),
402            "token material leaked into Debug: {debug}"
403        );
404        // The header *name* is still useful for diagnosis.
405        assert!(debug.contains("Authorization"));
406        assert!(
407            debug.contains("bearer"),
408            "non-secret fields stay readable: {debug}"
409        );
410    }
411
412    #[test]
413    fn token_delegate_debug_hides_the_bearer_token() {
414        let payload = TokenDelegatePayload {
415            target_name: "billing-api".into(),
416            bearer_token: Some("MINTED-SECRET-BYTES".into()),
417            ..Default::default()
418        };
419
420        let debug = format!("{payload:?}");
421        assert!(
422            !debug.contains("MINTED-SECRET-BYTES"),
423            "delegated token leaked into Debug: {debug}"
424        );
425        // Presence is diagnosable without exposing the value.
426        assert!(debug.contains("billing-api"));
427        assert!(debug.contains(REDACTED));
428    }
429
430    #[test]
431    fn token_delegate_debug_distinguishes_absent_from_present() {
432        let absent = format!(
433            "{:?}",
434            TokenDelegatePayload {
435                target_name: "x".into(),
436                ..Default::default()
437            }
438        );
439        assert!(
440            absent.contains("None"),
441            "an absent token should read as None: {absent}"
442        );
443        assert!(!absent.contains(REDACTED));
444    }
445}