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    pub const fn kind(&self) -> AdapterKind {
256        self.kind
257    }
258
259    /// Apply behavior mutations to `config` and return the resulting plan.
260    pub fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
261        self.inner.apply(config)
262    }
263}
264
265struct RuntimePolicyAdapter {
266    kind: AdapterKind,
267    policy: RuntimePolicyInput,
268}
269
270impl BrowserBehaviorAdapter for RuntimePolicyAdapter {
271    fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
272        let interaction = self.policy.telemetry_level.to_interaction_level();
273        let stealth_level = stealth_level_for_policy(&self.policy);
274
275        config.stealth_level = stealth_level;
276        config.cdp_fix_mode = if matches!(self.policy.execution_mode, ExecutionMode::Browser) {
277            CdpFixMode::AddBinding
278        } else {
279            CdpFixMode::None
280        };
281
282        #[cfg(feature = "stealth")]
283        {
284            if self.policy.enforce_webrtc_proxy_only {
285                config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
286            }
287        }
288
289        apply_config_hints(config, &self.policy.config_hints);
290
291        AppliedBehaviorPlan {
292            adapter_kind: self.kind,
293            execution_mode: self.policy.execution_mode,
294            session_mode: self.policy.session_mode,
295            interaction_level: interaction,
296            rate_limit_rps: self.policy.rate_limit_rps,
297            max_retries: self.policy.max_retries,
298            backoff_base_ms: self.policy.backoff_base_ms,
299            enable_warmup: self.policy.enable_warmup,
300            sticky_session_ttl_secs: self.policy.sticky_session_ttl_secs,
301            risk_score: clamp_unit(self.policy.risk_score),
302            required_stygian_features: self.policy.required_stygian_features.clone(),
303            config_hints: self.policy.config_hints.clone(),
304        }
305    }
306}
307
308struct DirectOverridesAdapter {
309    overrides: DirectOverridesInput,
310}
311
312impl BrowserBehaviorAdapter for DirectOverridesAdapter {
313    fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
314        if let Some(headless) = self.overrides.headless {
315            config.headless = headless;
316        }
317        if let Some(stealth) = self.overrides.stealth_level {
318            config.stealth_level = stealth;
319        }
320
321        #[cfg(feature = "stealth")]
322        {
323            if self.overrides.enforce_webrtc_proxy_only == Some(true) {
324                config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
325            }
326        }
327
328        let hints = self.overrides.config_hints.clone().unwrap_or_default();
329        apply_config_hints(config, &hints);
330
331        let execution_mode = self
332            .overrides
333            .execution_mode
334            .unwrap_or(ExecutionMode::Browser);
335        let session_mode = self
336            .overrides
337            .session_mode
338            .unwrap_or(SessionMode::Stateless);
339        let telemetry = self
340            .overrides
341            .telemetry_level
342            .unwrap_or(TelemetryLevel::Standard);
343        let interaction = self
344            .overrides
345            .interaction_level
346            .unwrap_or_else(|| telemetry.to_interaction_level());
347
348        AppliedBehaviorPlan {
349            adapter_kind: AdapterKind::DirectOverrides,
350            execution_mode,
351            session_mode,
352            interaction_level: interaction,
353            rate_limit_rps: self.overrides.rate_limit_rps.unwrap_or(1.0),
354            max_retries: self.overrides.max_retries.unwrap_or(2),
355            backoff_base_ms: self.overrides.backoff_base_ms.unwrap_or(500),
356            enable_warmup: self.overrides.enable_warmup.unwrap_or(false),
357            sticky_session_ttl_secs: self.overrides.sticky_session_ttl_secs,
358            risk_score: clamp_unit(self.overrides.risk_score.unwrap_or(0.5)),
359            required_stygian_features: self
360                .overrides
361                .required_stygian_features
362                .clone()
363                .unwrap_or_default(),
364            config_hints: hints,
365        }
366    }
367}
368
369fn stealth_level_for_policy(policy: &RuntimePolicyInput) -> StealthLevel {
370    if matches!(policy.execution_mode, ExecutionMode::Http) {
371        return StealthLevel::None;
372    }
373
374    if policy
375        .required_stygian_features
376        .iter()
377        .any(|f| f.contains("stealth") || f.contains("browser"))
378        || policy.risk_score >= 0.65
379    {
380        StealthLevel::Advanced
381    } else if policy.risk_score >= 0.25 {
382        StealthLevel::Basic
383    } else {
384        StealthLevel::None
385    }
386}
387
388fn apply_config_hints(config: &mut BrowserConfig, hints: &BTreeMap<String, String>) {
389    if let Some(proxy) = hints.get("proxy_url").or_else(|| hints.get("proxy")) {
390        config.proxy = Some(proxy.clone());
391    }
392
393    if let Some(headless_raw) = hints.get("headless")
394        && let Ok(headless) = headless_raw.parse::<bool>()
395    {
396        config.headless = headless;
397    }
398
399    if let (Some(width_raw), Some(height_raw)) =
400        (hints.get("viewport_width"), hints.get("viewport_height"))
401        && let (Ok(width), Ok(height)) = (width_raw.parse::<u32>(), height_raw.parse::<u32>())
402    {
403        config.window_size = Some((width, height));
404    }
405
406    if let Some(mode_raw) = hints.get("cdp_fix_mode") {
407        config.cdp_fix_mode = parse_cdp_fix_mode(mode_raw);
408    }
409
410    if let Some(user_agent) = hints.get("user_agent") {
411        let arg = format!("--user-agent={user_agent}");
412        if !config.args.iter().any(|existing| existing == &arg) {
413            config.args.push(arg);
414        }
415    }
416}
417
418fn parse_cdp_fix_mode(raw: &str) -> CdpFixMode {
419    match raw.to_ascii_lowercase().as_str() {
420        "none" | "0" => CdpFixMode::None,
421        "isolatedworld" | "isolated_world" | "isolated" => CdpFixMode::IsolatedWorld,
422        "enabledisable" | "enable_disable" => CdpFixMode::EnableDisable,
423        _ => CdpFixMode::AddBinding,
424    }
425}
426
427const fn clamp_unit(value: f64) -> f64 {
428    if value < 0.0 {
429        0.0
430    } else if value > 1.0 {
431        1.0
432    } else {
433        value
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use serde_json::json;
441
442    #[test]
443    fn selects_runtime_policy_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
444        let value = json!({
445            "execution_mode": "Browser",
446            "session_mode": "Sticky",
447            "telemetry_level": "Deep",
448            "rate_limit_rps": 0.5,
449            "max_retries": 3,
450            "backoff_base_ms": 1200,
451            "enable_warmup": true,
452            "enforce_webrtc_proxy_only": true,
453            "sticky_session_ttl_secs": 1800,
454            "required_stygian_features": ["browser", "stealth"],
455            "config_hints": {"proxy_url": "http://127.0.0.1:8080"},
456            "risk_score": 0.9
457        });
458
459        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
460            .map_err(|e| format!("adapter parse failed: {e}"))?;
461        assert_eq!(adapter.kind(), AdapterKind::RuntimePolicy);
462
463        let mut cfg = BrowserConfig::default();
464        let plan = adapter.apply(&mut cfg);
465        assert_eq!(plan.interaction_level, BehaviorInteractionLevel::High);
466        assert_eq!(cfg.proxy.as_deref(), Some("http://127.0.0.1:8080"));
467        assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
468        Ok(())
469    }
470
471    #[test]
472    fn selects_investigation_bundle_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
473        let value = json!({
474            "report": {},
475            "requirements": {},
476            "policy": {
477                "execution_mode": "Browser",
478                "session_mode": "Stateless",
479                "telemetry_level": "Standard",
480                "rate_limit_rps": 1.2,
481                "max_retries": 2,
482                "backoff_base_ms": 400,
483                "enable_warmup": false,
484                "enforce_webrtc_proxy_only": false,
485                "sticky_session_ttl_secs": null,
486                "required_stygian_features": [],
487                "config_hints": {},
488                "risk_score": 0.2
489            }
490        });
491
492        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
493            .map_err(|e| format!("adapter parse failed: {e}"))?;
494        assert_eq!(adapter.kind(), AdapterKind::InvestigationBundle);
495
496        let mut cfg = BrowserConfig::default();
497        let plan = adapter.apply(&mut cfg);
498        assert_eq!(plan.execution_mode, ExecutionMode::Browser);
499        assert_eq!(plan.session_mode, SessionMode::Stateless);
500        Ok(())
501    }
502
503    #[test]
504    fn direct_overrides_apply_to_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
505        let value = json!({
506            "headless": false,
507            "stealth_level": "basic",
508            "interaction_level": "medium",
509            "config_hints": {
510                "viewport_width": "1366",
511                "viewport_height": "768",
512                "user_agent": "Mozilla/5.0 test"
513            }
514        });
515
516        let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
517            .map_err(|e| format!("adapter parse failed: {e}"))?;
518        assert_eq!(adapter.kind(), AdapterKind::DirectOverrides);
519
520        let mut cfg = BrowserConfig::default();
521        let plan = adapter.apply(&mut cfg);
522
523        assert!(!cfg.headless);
524        assert_eq!(cfg.stealth_level, StealthLevel::Basic);
525        assert_eq!(plan.interaction_level, BehaviorInteractionLevel::Medium);
526        assert_eq!(cfg.window_size, Some((1366, 768)));
527        assert!(
528            cfg.args
529                .iter()
530                .any(|arg| arg.contains("--user-agent=Mozilla/5.0 test"))
531        );
532        Ok(())
533    }
534
535    #[test]
536    fn invalid_non_object_input_is_rejected() {
537        let err = PolymorphicBehaviorAdapter::from_json_value(json!("not-object"))
538            .err()
539            .map(|e| e.to_string())
540            .unwrap_or_default();
541        assert!(err.contains("must be a JSON object"));
542    }
543}