Skip to main content

stygian_browser/
behavior_adapter.rs

1//! Polymorphic adapter for structured JSON-driven browser behavior tuning.
2//!
3//! This module accepts multiple JSON shapes and maps them into concrete
4//! `stygian-browser` runtime behavior by mutating [`crate::BrowserConfig`] and
5//! returning an [`AppliedBehaviorPlan`] for runtime orchestration.
6//!
7//! Supported input envelopes:
8//! - Direct runtime policy object (`execution_mode`, `session_mode`, ...)
9//! - Full investigation bundle object with nested `policy` field
10//! - Lightweight direct override object (`headless`, `stealth_level`, ...)
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::{
18    BrowserConfig,
19    cdp_protection::CdpFixMode,
20    config::StealthLevel,
21    error::{BrowserError, Result},
22};
23
24#[cfg(feature = "stealth")]
25use crate::webrtc::WebRtcPolicy;
26
27/// Source JSON shape selected by the polymorphic adapter.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AdapterKind {
31    /// A direct runtime policy object was provided.
32    RuntimePolicy,
33    /// A full investigation bundle object with nested `policy` was provided.
34    InvestigationBundle,
35    /// A direct override object was provided.
36    DirectOverrides,
37}
38
39/// Browser execution mode.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ExecutionMode {
42    /// Lightweight HTTP-oriented mode.
43    Http,
44    /// Full browser automation mode.
45    Browser,
46}
47
48/// Session stickiness mode.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum SessionMode {
51    /// Stateless sessions.
52    Stateless,
53    /// Sticky sessions.
54    Sticky,
55}
56
57/// Telemetry intensity level.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59pub enum TelemetryLevel {
60    /// Minimal telemetry.
61    Basic,
62    /// Balanced telemetry.
63    Standard,
64    /// Deep telemetry.
65    Deep,
66}
67
68/// Interaction intensity recommendation for runtime page humanization.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum BehaviorInteractionLevel {
72    /// No interaction simulation.
73    None,
74    /// Light interaction simulation.
75    Low,
76    /// Moderate interaction simulation.
77    Medium,
78    /// High interaction simulation.
79    High,
80}
81
82impl TelemetryLevel {
83    const fn to_interaction_level(self) -> BehaviorInteractionLevel {
84        match self {
85            Self::Basic => BehaviorInteractionLevel::Low,
86            Self::Standard => BehaviorInteractionLevel::Medium,
87            Self::Deep => BehaviorInteractionLevel::High,
88        }
89    }
90}
91
92/// Structured behavior plan produced after JSON adaptation.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct AppliedBehaviorPlan {
95    /// Which adapter shape was selected.
96    pub adapter_kind: AdapterKind,
97    /// Effective execution mode.
98    pub execution_mode: ExecutionMode,
99    /// Effective session mode.
100    pub session_mode: SessionMode,
101    /// Effective interaction recommendation.
102    pub interaction_level: BehaviorInteractionLevel,
103    /// Effective request pacing budget (requests/second).
104    pub rate_limit_rps: f64,
105    /// Retry budget for request orchestration.
106    pub max_retries: u32,
107    /// Base backoff delay in milliseconds.
108    pub backoff_base_ms: u64,
109    /// Whether warmup routines should run before primary navigation.
110    pub enable_warmup: bool,
111    /// Sticky session TTL recommendation in seconds.
112    pub sticky_session_ttl_secs: Option<u64>,
113    /// Policy risk score in `[0.0, 1.0]`.
114    pub risk_score: f64,
115    /// Required feature labels inferred from policy.
116    pub required_stygian_features: Vec<String>,
117    /// Config hint passthrough map.
118    pub config_hints: BTreeMap<String, String>,
119}
120
121/// Trait for behavior adapters that can mutate a browser config.
122pub trait BrowserBehaviorAdapter {
123    /// Apply behavior to `config` and return the derived runtime plan.
124    fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan;
125}
126
127/// Runtime-policy input compatible with stygian-charon output.
128#[derive(Debug, Clone, Deserialize)]
129struct RuntimePolicyInput {
130    execution_mode: ExecutionMode,
131    session_mode: SessionMode,
132    telemetry_level: TelemetryLevel,
133    rate_limit_rps: f64,
134    max_retries: u32,
135    backoff_base_ms: u64,
136    enable_warmup: bool,
137    enforce_webrtc_proxy_only: bool,
138    sticky_session_ttl_secs: Option<u64>,
139    required_stygian_features: Vec<String>,
140    config_hints: BTreeMap<String, String>,
141    risk_score: f64,
142}
143
144/// Investigation-bundle input with nested runtime policy.
145#[derive(Debug, Clone, Deserialize)]
146struct InvestigationBundleInput {
147    policy: RuntimePolicyInput,
148}
149
150/// Direct JSON overrides for behavior tuning.
151#[derive(Debug, Clone, Default, Deserialize)]
152struct DirectOverridesInput {
153    execution_mode: Option<ExecutionMode>,
154    session_mode: Option<SessionMode>,
155    telemetry_level: Option<TelemetryLevel>,
156    interaction_level: Option<BehaviorInteractionLevel>,
157    stealth_level: Option<StealthLevel>,
158    headless: Option<bool>,
159    rate_limit_rps: Option<f64>,
160    max_retries: Option<u32>,
161    backoff_base_ms: Option<u64>,
162    enable_warmup: Option<bool>,
163    enforce_webrtc_proxy_only: Option<bool>,
164    sticky_session_ttl_secs: Option<u64>,
165    required_stygian_features: Option<Vec<String>>,
166    config_hints: Option<BTreeMap<String, String>>,
167    risk_score: Option<f64>,
168}
169
170/// Polymorphic adapter selected from structured JSON input.
171pub struct PolymorphicBehaviorAdapter {
172    kind: AdapterKind,
173    inner: Box<dyn BrowserBehaviorAdapter + Send + Sync>,
174}
175
176impl PolymorphicBehaviorAdapter {
177    /// Build an adapter from a JSON string.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`BrowserError::ConfigError`] when JSON parsing fails or the
182    /// structure is invalid.
183    ///
184    /// # Example
185    ///
186    /// ```
187    /// use stygian_browser::behavior_adapter::PolymorphicBehaviorAdapter;
188    /// use stygian_browser::BrowserConfig;
189    ///
190    /// let json = r#"{"execution_mode":"Browser","session_mode":"Sticky","telemetry_level":"Deep","rate_limit_rps":0.8,"max_retries":4,"backoff_base_ms":1200,"enable_warmup":true,"enforce_webrtc_proxy_only":true,"sticky_session_ttl_secs":1800,"required_stygian_features":["browser"],"config_hints":{},"risk_score":0.9}"#;
191    /// let adapter = PolymorphicBehaviorAdapter::from_json_str(json).expect("valid adapter");
192    /// let mut cfg = BrowserConfig::default();
193    /// let plan = adapter.apply(&mut cfg);
194    /// assert!(plan.enable_warmup);
195    /// ```
196    pub fn from_json_str(json: &str) -> Result<Self> {
197        let value: Value = serde_json::from_str(json)
198            .map_err(|e| BrowserError::ConfigError(format!("Invalid behavior JSON: {e}")))?;
199        Self::from_json_value(value)
200    }
201
202    /// Build an adapter from a JSON value.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`BrowserError::ConfigError`] when the value does not match any
207    /// supported input envelope.
208    pub fn from_json_value(value: Value) -> Result<Self> {
209        let obj = value.as_object().ok_or_else(|| {
210            BrowserError::ConfigError("Behavior input must be a JSON object".to_string())
211        })?;
212
213        if obj.contains_key("policy") {
214            let parsed: InvestigationBundleInput = serde_json::from_value(value).map_err(|e| {
215                BrowserError::ConfigError(format!(
216                    "Invalid investigation bundle behavior input: {e}"
217                ))
218            })?;
219            return Ok(Self {
220                kind: AdapterKind::InvestigationBundle,
221                inner: Box::new(RuntimePolicyAdapter {
222                    kind: AdapterKind::InvestigationBundle,
223                    policy: parsed.policy,
224                }),
225            });
226        }
227
228        if obj.contains_key("execution_mode")
229            && obj.contains_key("session_mode")
230            && obj.contains_key("telemetry_level")
231        {
232            let parsed: RuntimePolicyInput = serde_json::from_value(value).map_err(|e| {
233                BrowserError::ConfigError(format!("Invalid runtime policy behavior input: {e}"))
234            })?;
235            return Ok(Self {
236                kind: AdapterKind::RuntimePolicy,
237                inner: Box::new(RuntimePolicyAdapter {
238                    kind: AdapterKind::RuntimePolicy,
239                    policy: parsed,
240                }),
241            });
242        }
243
244        let parsed: DirectOverridesInput = serde_json::from_value(value).map_err(|e| {
245            BrowserError::ConfigError(format!("Invalid direct override behavior input: {e}"))
246        })?;
247
248        Ok(Self {
249            kind: AdapterKind::DirectOverrides,
250            inner: Box::new(DirectOverridesAdapter { overrides: parsed }),
251        })
252    }
253
254    /// Return the selected adapter kind.
255    #[must_use]
256    pub const fn kind(&self) -> AdapterKind {
257        self.kind
258    }
259
260    /// Apply behavior mutations to `config` and return the resulting plan.
261    pub fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
262        self.inner.apply(config)
263    }
264}
265
266struct RuntimePolicyAdapter {
267    kind: AdapterKind,
268    policy: RuntimePolicyInput,
269}
270
271impl BrowserBehaviorAdapter for RuntimePolicyAdapter {
272    fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
273        let interaction = self.policy.telemetry_level.to_interaction_level();
274        let stealth_level = stealth_level_for_policy(&self.policy);
275
276        config.stealth_level = stealth_level;
277        config.cdp_fix_mode = if matches!(self.policy.execution_mode, ExecutionMode::Browser) {
278            CdpFixMode::AddBinding
279        } else {
280            CdpFixMode::None
281        };
282
283        #[cfg(feature = "stealth")]
284        {
285            if self.policy.enforce_webrtc_proxy_only {
286                config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
287            }
288        }
289
290        apply_config_hints(config, &self.policy.config_hints);
291
292        AppliedBehaviorPlan {
293            adapter_kind: self.kind,
294            execution_mode: self.policy.execution_mode,
295            session_mode: self.policy.session_mode,
296            interaction_level: interaction,
297            rate_limit_rps: self.policy.rate_limit_rps,
298            max_retries: self.policy.max_retries,
299            backoff_base_ms: self.policy.backoff_base_ms,
300            enable_warmup: self.policy.enable_warmup,
301            sticky_session_ttl_secs: self.policy.sticky_session_ttl_secs,
302            risk_score: clamp_unit(self.policy.risk_score),
303            required_stygian_features: self.policy.required_stygian_features.clone(),
304            config_hints: self.policy.config_hints.clone(),
305        }
306    }
307}
308
309struct DirectOverridesAdapter {
310    overrides: DirectOverridesInput,
311}
312
313impl BrowserBehaviorAdapter for DirectOverridesAdapter {
314    fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
315        if let Some(headless) = self.overrides.headless {
316            config.headless = headless;
317        }
318        if let Some(stealth) = self.overrides.stealth_level {
319            config.stealth_level = stealth;
320        }
321
322        #[cfg(feature = "stealth")]
323        {
324            if self.overrides.enforce_webrtc_proxy_only == Some(true) {
325                config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
326            }
327        }
328
329        let hints = self.overrides.config_hints.clone().unwrap_or_default();
330        apply_config_hints(config, &hints);
331
332        let execution_mode = self
333            .overrides
334            .execution_mode
335            .unwrap_or(ExecutionMode::Browser);
336        let session_mode = self
337            .overrides
338            .session_mode
339            .unwrap_or(SessionMode::Stateless);
340        let telemetry = self
341            .overrides
342            .telemetry_level
343            .unwrap_or(TelemetryLevel::Standard);
344        let interaction = self
345            .overrides
346            .interaction_level
347            .unwrap_or_else(|| telemetry.to_interaction_level());
348
349        AppliedBehaviorPlan {
350            adapter_kind: AdapterKind::DirectOverrides,
351            execution_mode,
352            session_mode,
353            interaction_level: interaction,
354            rate_limit_rps: self.overrides.rate_limit_rps.unwrap_or(1.0),
355            max_retries: self.overrides.max_retries.unwrap_or(2),
356            backoff_base_ms: self.overrides.backoff_base_ms.unwrap_or(500),
357            enable_warmup: self.overrides.enable_warmup.unwrap_or(false),
358            sticky_session_ttl_secs: self.overrides.sticky_session_ttl_secs,
359            risk_score: clamp_unit(self.overrides.risk_score.unwrap_or(0.5)),
360            required_stygian_features: self
361                .overrides
362                .required_stygian_features
363                .clone()
364                .unwrap_or_default(),
365            config_hints: hints,
366        }
367    }
368}
369
370fn stealth_level_for_policy(policy: &RuntimePolicyInput) -> StealthLevel {
371    if matches!(policy.execution_mode, ExecutionMode::Http) {
372        return StealthLevel::None;
373    }
374
375    if policy
376        .required_stygian_features
377        .iter()
378        .any(|f| f.contains("stealth") || f.contains("browser"))
379        || policy.risk_score >= 0.65
380    {
381        StealthLevel::Advanced
382    } else if policy.risk_score >= 0.25 {
383        StealthLevel::Basic
384    } else {
385        StealthLevel::None
386    }
387}
388
389fn apply_config_hints(config: &mut BrowserConfig, hints: &BTreeMap<String, String>) {
390    if let Some(proxy) = hints.get("proxy_url").or_else(|| hints.get("proxy")) {
391        config.proxy = Some(proxy.clone());
392    }
393
394    if let Some(headless_raw) = hints.get("headless")
395        && let Ok(headless) = headless_raw.parse::<bool>()
396    {
397        config.headless = headless;
398    }
399
400    if let (Some(width_raw), Some(height_raw)) =
401        (hints.get("viewport_width"), hints.get("viewport_height"))
402        && let (Ok(width), Ok(height)) = (width_raw.parse::<u32>(), height_raw.parse::<u32>())
403    {
404        config.window_size = Some((width, height));
405    }
406
407    if let Some(mode_raw) = hints.get("cdp_fix_mode") {
408        config.cdp_fix_mode = parse_cdp_fix_mode(mode_raw);
409    }
410
411    if let Some(user_agent) = hints.get("user_agent") {
412        let arg = format!("--user-agent={user_agent}");
413        if !config.args.iter().any(|existing| existing == &arg) {
414            config.args.push(arg);
415        }
416    }
417}
418
419fn parse_cdp_fix_mode(raw: &str) -> CdpFixMode {
420    match raw.to_ascii_lowercase().as_str() {
421        "none" | "0" => CdpFixMode::None,
422        "isolatedworld" | "isolated_world" | "isolated" => CdpFixMode::IsolatedWorld,
423        "enabledisable" | "enable_disable" => CdpFixMode::EnableDisable,
424        _ => CdpFixMode::AddBinding,
425    }
426}
427
428const fn clamp_unit(value: f64) -> f64 {
429    if value < 0.0 {
430        0.0
431    } else if value > 1.0 {
432        1.0
433    } else {
434        value
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use serde_json::json;
442
443    #[test]
444    fn selects_runtime_policy_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
445        let value = json!({
446            "execution_mode": "Browser",
447            "session_mode": "Sticky",
448            "telemetry_level": "Deep",
449            "rate_limit_rps": 0.5,
450            "max_retries": 3,
451            "backoff_base_ms": 1200,
452            "enable_warmup": true,
453            "enforce_webrtc_proxy_only": true,
454            "sticky_session_ttl_secs": 1800,
455            "required_stygian_features": ["browser", "stealth"],
456            "config_hints": {"proxy_url": "http://127.0.0.1:8080"},
457            "risk_score": 0.9
458        });
459
460        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
461            .map_err(|e| format!("adapter parse failed: {e}"))?;
462        assert_eq!(adapter.kind(), AdapterKind::RuntimePolicy);
463
464        let mut cfg = BrowserConfig::default();
465        let plan = adapter.apply(&mut cfg);
466        assert_eq!(plan.interaction_level, BehaviorInteractionLevel::High);
467        assert_eq!(cfg.proxy.as_deref(), Some("http://127.0.0.1:8080"));
468        assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
469        Ok(())
470    }
471
472    #[test]
473    fn selects_investigation_bundle_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
474        let value = json!({
475            "report": {},
476            "requirements": {},
477            "policy": {
478                "execution_mode": "Browser",
479                "session_mode": "Stateless",
480                "telemetry_level": "Standard",
481                "rate_limit_rps": 1.2,
482                "max_retries": 2,
483                "backoff_base_ms": 400,
484                "enable_warmup": false,
485                "enforce_webrtc_proxy_only": false,
486                "sticky_session_ttl_secs": null,
487                "required_stygian_features": [],
488                "config_hints": {},
489                "risk_score": 0.2
490            }
491        });
492
493        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
494            .map_err(|e| format!("adapter parse failed: {e}"))?;
495        assert_eq!(adapter.kind(), AdapterKind::InvestigationBundle);
496
497        let mut cfg = BrowserConfig::default();
498        let plan = adapter.apply(&mut cfg);
499        assert_eq!(plan.execution_mode, ExecutionMode::Browser);
500        assert_eq!(plan.session_mode, SessionMode::Stateless);
501        Ok(())
502    }
503
504    #[test]
505    fn direct_overrides_apply_to_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
506        let value = json!({
507            "headless": false,
508            "stealth_level": "basic",
509            "interaction_level": "medium",
510            "config_hints": {
511                "viewport_width": "1366",
512                "viewport_height": "768",
513                "user_agent": "Mozilla/5.0 test"
514            }
515        });
516
517        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
518            .map_err(|e| format!("adapter parse failed: {e}"))?;
519        assert_eq!(adapter.kind(), AdapterKind::DirectOverrides);
520
521        let mut cfg = BrowserConfig::default();
522        let plan = adapter.apply(&mut cfg);
523
524        assert!(!cfg.headless);
525        assert_eq!(cfg.stealth_level, StealthLevel::Basic);
526        assert_eq!(plan.interaction_level, BehaviorInteractionLevel::Medium);
527        assert_eq!(cfg.window_size, Some((1366, 768)));
528        assert!(
529            cfg.args
530                .iter()
531                .any(|arg| arg.contains("--user-agent=Mozilla/5.0 test"))
532        );
533        Ok(())
534    }
535
536    #[test]
537    fn invalid_non_object_input_is_rejected() {
538        let err = PolymorphicBehaviorAdapter::from_json_value(json!("not-object"))
539            .err()
540            .map(|e| e.to_string())
541            .unwrap_or_default();
542        assert!(err.contains("must be a JSON object"));
543    }
544}