Skip to main content

k_ruoka_mcp/browser/
session.rs

1//! Persistent-profile Chrome session, and the classification of what a failed
2//! `/kr-api/` call actually means.
3//!
4//! Everything here rests on one measured fact: a real Chrome whose
5//! User-Agent does not say `HeadlessChrome` clears Cloudflare on k-ruoka.fi
6//! unaided, and a same-origin `fetch()` from inside the loaded page carries the
7//! session cookies without any manual cookie handling.
8
9use std::path::{Path, PathBuf};
10use std::sync::Mutex as StdMutex;
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::time::{Duration, Instant};
13
14use anyhow::{Context, Result};
15use chromiumoxide::browser::{Browser, BrowserConfig};
16use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
17use chromiumoxide::{Page, error::CdpError};
18use futures::StreamExt;
19use serde::Deserialize;
20use tokio::sync::Mutex;
21use tokio::task::JoinHandle;
22
23pub const SHOP_URL: &str = "https://www.k-ruoka.fi/kauppa";
24pub const SHOP_ORIGIN: &str = "https://www.k-ruoka.fi";
25
26/// `starts_with(SHOP_ORIGIN)` also accepts `www.k-ruoka.fi.example.net` and
27/// `www.k-ruoka.fi@evil.example`, so the next character has to be a boundary. No URL
28/// parser needed: this is Chrome's own serialized URL, already normalized.
29fn on_shop_origin(url: &str) -> bool {
30    match url.strip_prefix(SHOP_ORIGIN) {
31        Some(rest) => rest.is_empty() || rest.starts_with(['/', '?', '#']),
32        None => false,
33    }
34}
35
36/// A refusal: we are being turned away and waiting will not change that.
37///
38/// `Pyyntö estetty` ("request blocked") is K-Ruoka's own WAF page; `Attention
39/// Required` is Cloudflare's.
40const BLOCK_MARKERS: &[&str] = &["Pyyntö estetty", "Attention Required"];
41
42/// A challenge *in progress*. Not a refusal: a real browser runs the JavaScript and
43/// it clears itself, which is the entire premise of this design. During a
44/// page load these mean "not ready yet, keep waiting", NOT "give up" -- conflating
45/// the two would stop the browser from doing the one thing it is here to do.
46const CHALLENGE_MARKERS: &[&str] = &["Just a moment", "cdn-cgi/challenge"];
47
48fn first_marker(text: &str, markers: &[&'static str]) -> Option<&'static str> {
49    markers.iter().copied().find(|m| text.contains(m))
50}
51
52/// Any Cloudflare fingerprint at all. Correct for classifying an API *response*: a
53/// challenge page arriving in place of JSON is a failure for that request, however
54/// transient the underlying condition.
55fn cloudflare_marker(text: &str) -> Option<&'static str> {
56    first_marker(text, BLOCK_MARKERS).or_else(|| first_marker(text, CHALLENGE_MARKERS))
57}
58const CLEARANCE_TIMEOUT: Duration = Duration::from_secs(45);
59
60/// How long a browser gets to exit after being asked, before it is killed.
61const GRACEFUL_EXIT: Duration = Duration::from_secs(10);
62
63/// chromiumoxide's `DEFAULT_ARGS` carry `--enable-automation` (a bot signal) and
64/// `--lang=en_US`, and its `ArgsBuilder` *merges* repeated keys instead of
65/// overriding them, so `lang=fi-FI` on top would produce `--lang=en_US,fi-FI`.
66/// We therefore opt out of the defaults and curate the list.
67///
68/// No leading `--`: chromiumoxide's `Arg` takes the whole string as the flag key
69/// and prepends the dashes itself. `"--foo"` here becomes `----foo`, which Chrome
70/// ignores in silence.
71const CHROME_ARGS: &[&str] = &[
72    "disable-background-networking",
73    "disable-background-timer-throttling",
74    "disable-backgrounding-occluded-windows",
75    "disable-breakpad",
76    "disable-client-side-phishing-detection",
77    "disable-default-apps",
78    "disable-dev-shm-usage",
79    "disable-hang-monitor",
80    "disable-ipc-flooding-protection",
81    "disable-popup-blocking",
82    "disable-prompt-on-repost",
83    "disable-renderer-backgrounding",
84    "disable-sync",
85    "metrics-recording-only",
86    "no-first-run",
87    "password-store=basic",
88    "use-mock-keychain",
89    "disable-blink-features=AutomationControlled",
90    "lang=fi-FI",
91];
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum LaunchMode {
95    /// Headless, for `serve`.
96    Headless,
97    /// Headful, for `login`. Needs an X display (see `login`'s xvfb-run re-exec).
98    Headful { debug_port: u16 },
99}
100
101/// Why a `/kr-api/` call did not produce a usable answer.
102///
103/// [`ApiError::Cloudflare`] and [`ApiError::BrowserGone`] both relaunch against the
104/// *same* profile; [`ApiError::AuthExpired`] must never touch it, or a transient failure
105/// destroys a real login.
106#[derive(Debug, thiserror::Error)]
107pub enum ApiError {
108    /// Bot mitigation. Relaunch against the same profile dir; never delete it.
109    ///
110    /// Raised both by an API response that carries a Cloudflare fingerprint and by
111    /// the page load itself being rejected. Both need the same remedy, so both must
112    /// land here -- a block during navigation reported as [`ApiError::Other`] would
113    /// silently bypass the relaunch that is the only remedy for it.
114    #[error("Cloudflare blocked us: {detail}")]
115    Cloudflare { detail: String },
116
117    /// The transport to Chrome died. The browser is broken, not the login it holds, so
118    /// the remedy is the same relaunch as [`ApiError::Cloudflare`].
119    #[error("the browser connection was lost: {detail}")]
120    BrowserGone { detail: String },
121
122    /// The K-Plussa session is gone. Do not retry, do not touch the profile.
123    #[error("K-Plussa session has expired -- run `k-ruoka-mcp login` again")]
124    AuthExpired,
125
126    /// `X-K-Build-Number` was absent or unparseable. Recoverable: the 409 that
127    /// reports it carries the real value in its own `k-ruoka-build` header.
128    ///
129    /// Despite K-Ruoka's wording ("Client version is too old"), this does *not* mean
130    /// they deployed: the header is presence-checked and must parse as a number, but
131    /// the value is never compared. In practice this fires on a process's
132    /// first call, before the header has been learned.
133    #[error("stale X-K-Build-Number (server wants {wanted:?})")]
134    StaleBuild { wanted: Option<String> },
135
136    /// The API understood us and said no.
137    #[error("K-Ruoka API error (status {status}): {message}")]
138    Api { status: u16, message: String },
139
140    /// *We* rejected the request before sending it -- a bad argument, not a K-Ruoka
141    /// failure. Kept separate so the message cannot imply that K-Ruoka was asked and
142    /// refused. It still reaches the caller as `isError: true` content like every
143    /// other tool failure, deliberately: the model is meant to read it and try
144    /// something else, which a JSON-RPC `invalid_params` does not guarantee.
145    #[error("{0}")]
146    InvalidRequest(String),
147
148    #[error(transparent)]
149    Other(#[from] anyhow::Error),
150}
151
152/// The seam between cart logic and the browser.
153///
154/// Everything above this trait -- event construction, validation, the rollback of a
155/// phantom add, error mapping onto MCP -- is ordinary logic that has nothing to do
156/// with Chrome. Naming it lets the tests drive that logic against a fake K-Ruoka
157/// instead of a real browser and a live site, which is what makes it possible to
158/// test the signed-in branches at all without a login (see `tests/mcp_protocol.rs`).
159///
160/// Deliberately narrow: one method, the same shape as the underlying request. A
161/// wider trait would start duplicating [`crate::browser::basket::Cart`].
162#[async_trait::async_trait]
163pub trait KrApi: Send + Sync {
164    /// Named `call` rather than `api` so it cannot be confused with, or silently
165    /// shadowed by, [`Session::api`].
166    async fn call(
167        &self,
168        method: &str,
169        path: &str,
170        body: Option<&serde_json::Value>,
171    ) -> Result<serde_json::Value, ApiError>;
172}
173
174#[async_trait::async_trait]
175impl KrApi for Session {
176    async fn call(
177        &self,
178        method: &str,
179        path: &str,
180        body: Option<&serde_json::Value>,
181    ) -> Result<serde_json::Value, ApiError> {
182        self.api(method, path, body).await
183    }
184}
185
186/// What to do about a failed attempt. Extracted from [`Session::api`]'s loop so the
187/// policy can be tested exhaustively without a browser.
188#[derive(Debug, PartialEq, Eq)]
189enum Recovery {
190    /// Cache this build number and try again.
191    RefreshBuild(String),
192    /// Replace the browser and try again.
193    Relaunch,
194    /// Not recoverable, or the one retry has been spent.
195    GiveUp,
196}
197
198/// `relaunch_unavailable` / `refreshed_build` record whether that remedy is off the
199/// table -- either already spent on this request (each is allowed once, so a permanent
200/// failure terminates) or, for the relaunch, never permitted in the first place
201/// because a human is using the browser.
202fn plan_recovery(error: &ApiError, relaunch_unavailable: bool, refreshed_build: bool) -> Recovery {
203    match error {
204        // Only actionable when the 409 actually carried a value. Writing `None`
205        // through would clobber a known-good build for the rest of the process.
206        ApiError::StaleBuild {
207            wanted: Some(build),
208        } if !refreshed_build => Recovery::RefreshBuild(build.clone()),
209        ApiError::Cloudflare { .. } | ApiError::BrowserGone { .. } if !relaunch_unavailable => {
210            Recovery::Relaunch
211        }
212        // AuthExpired especially: retrying cannot help, and the profile must not be
213        // touched over it.
214        _ => Recovery::GiveUp,
215    }
216}
217
218/// Smallest gap between two `/kr-api/` requests.
219///
220/// Not a throughput limit; it is about shape. A model looping over a shopping list, or
221/// concurrent tool calls, would otherwise arrive as a burst. Slower than a human clicks,
222/// deliberately.
223const DEFAULT_MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(500);
224
225/// Spaces requests out so concurrent callers queue instead of bursting.
226///
227/// The lock is held across the sleep on purpose: that is what serialises the queue
228/// instead of letting everyone wake together.
229struct RateLimiter {
230    min_interval: Duration,
231    /// When the next request may go out. `None` until the first one.
232    next_allowed: Mutex<Option<Instant>>,
233}
234
235impl RateLimiter {
236    fn new(min_interval: Duration) -> Self {
237        Self {
238            min_interval,
239            next_allowed: Mutex::new(None),
240        }
241    }
242
243    /// Returns once the caller may make its request.
244    async fn acquire(&self) {
245        if self.min_interval.is_zero() {
246            return;
247        }
248        let mut slot = self.next_allowed.lock().await;
249        let now = Instant::now();
250        // A caller that arrives late has already waited; only an early one sleeps.
251        if let Some(next) = *slot
252            && let Some(wait) = next.checked_duration_since(now)
253        {
254            tokio::time::sleep(wait).await;
255        }
256        *slot = Some(Instant::now() + self.min_interval);
257    }
258}
259
260/// `K_RUOKA_MIN_REQUEST_INTERVAL_MS` overrides the spacing; `0` disables it.
261///
262/// The live suites deliberately do not set it: a limiter only ever run at a test value is
263/// one nobody has exercised. Costs them ~17 s.
264fn min_request_interval() -> Duration {
265    match std::env::var("K_RUOKA_MIN_REQUEST_INTERVAL_MS") {
266        Ok(raw) => match raw.trim().parse::<u64>() {
267            Ok(ms) => Duration::from_millis(ms),
268            // A typo must not silently remove the limit.
269            Err(_) => {
270                eprintln!(
271                    "k-ruoka-mcp: K_RUOKA_MIN_REQUEST_INTERVAL_MS={raw:?} is not a number \
272                     of milliseconds; using the default"
273                );
274                DEFAULT_MIN_REQUEST_INTERVAL
275            }
276        },
277        Err(_) => DEFAULT_MIN_REQUEST_INTERVAL,
278    }
279}
280
281/// The browser went away because the process is stopping.
282///
283/// Reached two ways, and both are ordinary rather than exceptional: asking for a
284/// browser after [`Session::close`], or having `close` empty the slot between
285/// `ensure_live` returning and the lock being re-acquired. `Other`, so
286/// [`plan_recovery`] gives up rather than retrying into a shutdown.
287fn closed_underneath_us() -> ApiError {
288    ApiError::Other(anyhow::anyhow!(
289        "the server is shutting down; no new browser will be started"
290    ))
291}
292
293/// Whether relaunching would destroy something a person is in the middle of.
294///
295/// Headful means `login`: the browser *is* the window the human is signing in through.
296/// A relaunch closes it and the replacement gets only the poller page back, so a
297/// transient block during the 15-minute poll would make a half-finished sign-in vanish
298/// with no explanation while `login` kept polling to its timeout. Failing one poll is
299/// the mild outcome: the next one is three seconds later. `login` protects the user's
300/// *tab* from being navigated for the same reason; this protects it from teardown.
301///
302/// Pure, so the mapping is pinned by a test: inverting it is silent, and the cost of
303/// getting it wrong is only ever paid by a human mid-password.
304fn relaunch_costs_a_human_their_login(mode: LaunchMode) -> bool {
305    match mode {
306        LaunchMode::Headful { .. } => true,
307        LaunchMode::Headless => false,
308    }
309}
310
311/// Whether [`Session::relaunch`] should replace what is in the slot.
312///
313/// `current` is the generation now live (`None` if nothing is), `blocked` the one the
314/// failed attempt used (`None` if it never got a browser). Split out for the same
315/// reason as [`plan_recovery`]: the *decision* is what silently regressed, and a check
316/// that needs a real browser cannot cover it. `plan_recovery` deciding `Relaunch` is
317/// worth nothing if this then declines to do it.
318fn should_replace(current: Option<u64>, blocked: Option<u64>) -> bool {
319    match (current, blocked) {
320        // Nothing live, so there is nothing to preserve and no way to be too late.
321        (None, _) => true,
322        // The browser that got blocked is still the live one: replace it.
323        (Some(current), Some(blocked)) => current == blocked,
324        // The caller never had a browser, so whatever is there arrived after it gave
325        // up and is by definition fresher.
326        (Some(_), None) => false,
327    }
328}
329
330/// A raw `/kr-api/` response, before classification.
331#[derive(Debug, Deserialize)]
332struct RawResponse {
333    status: u16,
334    build: Option<String>,
335    #[serde(rename = "cfMitigated")]
336    cf_mitigated: Option<String>,
337    #[serde(rename = "contentType")]
338    content_type: Option<String>,
339    body: String,
340}
341
342/// Shape of K-Ruoka's own error bodies, e.g.
343/// `{"error":{"message":"Client version is too old - reload"}}`.
344#[derive(Debug, Deserialize)]
345struct ApiErrorBody {
346    error: ApiErrorInner,
347}
348
349#[derive(Debug, Deserialize)]
350struct ApiErrorInner {
351    message: String,
352}
353
354struct Live {
355    browser: Browser,
356    page: Page,
357    handler: JoinHandle<()>,
358    /// Which incarnation of the browser this is. Lets a caller that took a page,
359    /// then hit a Cloudflare block, tell "the browser I used is still current, so
360    /// I should replace it" from "someone already replaced it, so I should just
361    /// retry". rmcp dispatches tool calls concurrently (measured), so without this
362    /// N simultaneously-blocked calls each tear down the browser the previous one
363    /// just built.
364    generation: u64,
365}
366
367impl Live {
368    /// Close the browser so Chrome flushes cookies into the profile.
369    ///
370    /// Dropping a `Live` instead kills Chrome in the background with no timing
371    /// guarantee, which loses the flush *and* can leave a stale `SingletonLock` for
372    /// the next launch against the same profile to trip over. Every *deliberate*
373    /// teardown path goes through here; a `Live` dropped because its task was
374    /// cancelled mid-launch still takes the ugly route, which is why the launch error
375    /// names a leftover Chrome as the likely cause rather than a corrupt profile.
376    async fn shutdown(mut self) {
377        // `close` is a CDP call, so a dead transport cannot answer it, and `wait` then
378        // blocks on a child nothing has asked to exit. That is exactly the state a
379        // BrowserGone relaunch starts from, so an unbounded wait here would hang the
380        // relaunch it is meant to enable. The timeout is far longer than a healthy Chrome
381        // needs to flush cookies and go.
382        let asked_to_exit = self.browser.close().await.is_ok();
383        let exited = asked_to_exit
384            && tokio::time::timeout(GRACEFUL_EXIT, self.browser.wait())
385                .await
386                .is_ok();
387        if !exited {
388            self.browser.kill().await;
389            self.browser.wait().await.ok();
390        }
391        self.handler.abort();
392    }
393}
394
395pub struct Session {
396    profile: PathBuf,
397    mode: LaunchMode,
398    /// Derived on first use, not in `new`. Deriving it reads Chrome's version, and
399    /// doing that eagerly put a Chrome probe in front of `serve`'s startup, which is
400    /// meant to be instant and to not need Chrome at all until a tool is called.
401    user_agent: std::sync::OnceLock<String>,
402    live: Mutex<Option<Live>>,
403    /// Set by [`Session::close`]. Stops a tool call that is still running from
404    /// launching a browser the process is about to abandon.
405    ///
406    /// Only ever read or written while holding the `live` lock, which is what makes
407    /// `Relaxed` sufficient and the check race-free.
408    closed: AtomicBool,
409    /// Unlike `closed`, set before the `live` lock is acquired so an in-progress poll can see it without the lock.
410    shutting_down: AtomicBool,
411    /// Set while an interactive login owns the profile. Same lock discipline as
412    /// `closed`: only touched under the `live` lock.
413    login_in_progress: AtomicBool,
414    /// Next value for `Live::generation`.
415    next_generation: AtomicU64,
416    /// `X-K-Build-Number`. Learned from any `/kr-api/` response's `k-ruoka-build`
417    /// header, including the 409 we get for not having sent it yet.
418    build: Mutex<Option<String>>,
419    /// Keeps request volume well below ordinary browsing, whatever the caller does.
420    limiter: RateLimiter,
421    /// Last time something touched this session's browser path.
422    last_activity: StdMutex<Instant>,
423    /// Browser operations currently in flight.
424    active_browser_ops: AtomicUsize,
425}
426
427struct BrowserActivity<'a> {
428    session: &'a Session,
429}
430
431impl Drop for BrowserActivity<'_> {
432    fn drop(&mut self) {
433        *self.session.last_activity.lock().unwrap() = Instant::now();
434        self.session
435            .active_browser_ops
436            .fetch_sub(1, Ordering::Relaxed);
437    }
438}
439
440impl Session {
441    pub fn new(profile: impl Into<PathBuf>, mode: LaunchMode) -> Result<Self> {
442        let profile = profile.into();
443        ensure_private_dir(&profile)?;
444        Ok(Self {
445            profile,
446            mode,
447            user_agent: std::sync::OnceLock::new(),
448            live: Mutex::new(None),
449            closed: AtomicBool::new(false),
450            shutting_down: AtomicBool::new(false),
451            login_in_progress: AtomicBool::new(false),
452            next_generation: AtomicU64::new(0),
453            build: Mutex::new(None),
454            limiter: RateLimiter::new(min_request_interval()),
455            last_activity: StdMutex::new(Instant::now()),
456            active_browser_ops: AtomicUsize::new(0),
457        })
458    }
459
460    async fn begin_browser_activity(&self) -> BrowserActivity<'_> {
461        let _guard = self.live.lock().await;
462        self.active_browser_ops.fetch_add(1, Ordering::Relaxed);
463        *self.last_activity.lock().unwrap() = Instant::now();
464        BrowserActivity { session: self }
465    }
466
467    /// The User-Agent this session presents to Cloudflare.
468    ///
469    /// Exposed because it is the single load-bearing fact here: a string
470    /// containing `HeadlessChrome` is blocked outright, one without it is served the
471    /// real shop. The spike prints it so a reader can see *why* the page loaded, and
472    /// so a regression in `user_agent()` shows up as evidence rather than as a bare
473    /// "Cloudflare blocked us".
474    pub fn user_agent(&self) -> Result<&str> {
475        if let Some(ua) = self.user_agent.get() {
476            return Ok(ua);
477        }
478        // Two callers racing here both derive the same string, and `get_or_init` keeps
479        // whichever lands first. Failure is deliberately not cached: a missing Chrome is
480        // worth retrying once the user installs one.
481        let derived = user_agent()?;
482        Ok(self.user_agent.get_or_init(|| derived))
483    }
484
485    /// Seed or clear the cached `X-K-Build-Number`.
486    ///
487    /// Normally it is learned automatically from any `/kr-api/` response header.
488    /// This exists so the stale-build retry can be exercised deliberately (see
489    /// `probe --build=<value>`) rather than only on a cold start.
490    pub async fn set_build(&self, build: Option<String>) {
491        *self.build.lock().await = build;
492    }
493
494    /// Launch the browser and park it on the shop page with Cloudflare cleared.
495    /// Idempotent; a session that is already live and still on k-ruoka.fi is left
496    /// alone, because relaunching per call would be slow and would fight over the
497    /// profile's single-instance lock.
498    async fn ensure_live(&self) -> Result<(), ApiError> {
499        let mut guard = self.live.lock().await;
500        self.refuse_if_unavailable()?;
501        if let Some(live) = guard.as_ref() {
502            // Cheap liveness probe: a dead browser fails this, a live one on the
503            // wrong URL just needs re-navigating.
504            match live.page.url().await {
505                // Prefix alone would accept `www.k-ruoka.fi.example.net` as arrived.
506                Ok(Some(url)) if on_shop_origin(&url) => return Ok(()),
507                Ok(_) => {
508                    // Tear the browser down if re-navigating fails. Leaving a blocked
509                    // one in the slot is what broke the relaunch: `attempt_once` has
510                    // no generation to attribute the failure to, so `relaunch` would
511                    // find a browser it could not match and no-op, spending the one
512                    // permitted retry on nothing. Matches the fresh-launch path below.
513                    if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
514                        if let Some(dead) = guard.take() {
515                            dead.shutdown().await;
516                        }
517                        return Err(e);
518                    }
519                    return Ok(());
520                }
521                Err(_) => {
522                    // Browser is gone; fall through and relaunch.
523                    if let Some(dead) = guard.take() {
524                        dead.handler.abort();
525                    }
526                }
527            }
528        }
529
530        let live = self.launch().await.map_err(ApiError::Other)?;
531        if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
532            live.shutdown().await;
533            return Err(e);
534        }
535        *guard = Some(live);
536        Ok(())
537    }
538
539    async fn launch(&self) -> Result<Live> {
540        let mut builder = BrowserConfig::builder()
541            .chrome_executable(chrome_path())
542            .user_data_dir(&self.profile)
543            .no_sandbox();
544        builder = match self.mode {
545            LaunchMode::Headless => builder.new_headless_mode(),
546            LaunchMode::Headful { debug_port } => builder.with_head().port(debug_port),
547        };
548        let config = builder
549            .disable_default_args()
550            .args(CHROME_ARGS.iter().copied())
551            .arg(format!("user-agent={}", self.user_agent()?))
552            .window_size(1440, 900)
553            .build()
554            .map_err(|e| anyhow::anyhow!("building BrowserConfig: {e}"))?;
555
556        let (browser, mut handler) = Browser::launch(config).await.with_context(|| {
557            format!(
558                "launching Chrome against profile {}. A Chrome left over from an \
559                 earlier run can hold the profile's lock -- check for one \
560                 (pkill -f {}) before considering the profile itself broken, because \
561                 it holds your login and re-running `login` is the only way back.",
562                self.profile.display(),
563                self.profile.display()
564            )
565        })?;
566        let handler = tokio::spawn(async move { while handler.next().await.is_some() {} });
567        let page = browser.new_page("about:blank").await?;
568        let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
569        // Same reasoning as the retry lines: a relaunch that silently no-ops is
570        // otherwise indistinguishable from one that worked, and that is exactly how
571        // the generation-sentinel bug survived. "relaunching..." with no launch after
572        // it is now visibly a lie.
573        eprintln!("k-ruoka-mcp: launched browser generation {generation}");
574        Ok(Live {
575            browser,
576            page,
577            handler,
578            generation,
579        })
580    }
581
582    /// The page to run a fetch on, plus the browser generation it belongs to.
583    /// Launches if needed.
584    ///
585    /// The slot can legitimately be empty by the time the lock is re-acquired: a
586    /// concurrent [`Session::close`] is exactly the interleaving `refuse_if_closed`
587    /// handles in the other order. This used to `expect`, which turned that ordering
588    /// into a panic inside a spawned tool task -- and asserted an invariant that
589    /// `close` can break by design.
590    async fn current_page(&self) -> Result<(Page, u64), ApiError> {
591        self.ensure_live().await?;
592        let guard = self.live.lock().await;
593        let live = guard.as_ref().ok_or_else(closed_underneath_us)?;
594        Ok((live.page.clone(), live.generation))
595    }
596
597    /// Open an additional tab that this `Session` does not manage.
598    ///
599    /// `login` needs this. The session's own page is where API calls run, and
600    /// `ensure_live` will navigate it back to the shop whenever it finds it on
601    /// another origin. Signing in goes via `login.kesko.fi`, a different origin --
602    /// so if the human were driving the session's page, the poller would yank it
603    /// back to `/kauppa` every few seconds, mid-login. Give them their own tab.
604    pub async fn open_extra_page(&self, url: &str) -> Result<Page> {
605        let _activity = self.begin_browser_activity().await;
606        self.ensure_live().await?;
607        let guard = self.live.lock().await;
608        // See `current_page`: a concurrent `close` can empty the slot legitimately.
609        let browser = &guard.as_ref().ok_or_else(closed_underneath_us)?.browser;
610        Ok(browser.new_page(url).await?)
611    }
612
613    /// The session's own page, for callers that need to poke at the DOM.
614    pub async fn with_page<T, F, Fut>(&self, f: F) -> Result<T>
615    where
616        F: FnOnce(Page) -> Fut,
617        Fut: std::future::Future<Output = Result<T>>,
618    {
619        let _activity = self.begin_browser_activity().await;
620        self.ensure_live().await?;
621        let page = {
622            let guard = self.live.lock().await;
623            // See `current_page`: a concurrent `close` can empty the slot legitimately.
624            guard
625                .as_ref()
626                .ok_or_else(closed_underneath_us)?
627                .page
628                .clone()
629        };
630        f(page).await
631    }
632
633    /// Tear down the browser so Chrome flushes cookies into the profile.
634    ///
635    /// Dropping it instead kills the process, and an unflushed profile is exactly
636    /// how a login silently fails to persist.
637    /// Ask every in-flight wait to give up, without awaiting anything.
638    ///
639    /// Separate from [`Session::close`] because shutdown has to stop a login first, and
640    /// that await can itself be queued behind the `live` lock this flag releases.
641    pub fn signal_shutdown(&self) {
642        self.shutting_down.store(true, Ordering::Relaxed);
643    }
644
645    pub async fn close(&self) -> Result<()> {
646        // Deliberately before the lock attempt; see `shutting_down`'s field docs.
647        self.signal_shutdown();
648        let mut guard = self.live.lock().await;
649        // Before the teardown, so a tool call still in flight cannot slip a fresh
650        // launch in behind us. On the signal path `serve` calls this and then
651        // `std::process::exit(0)`, which runs no destructors -- so a Chrome launched
652        // after this point is simply orphaned, holding the profile's SingletonLock
653        // and making the next `serve` fail to launch.
654        self.closed.store(true, Ordering::Relaxed);
655        if let Some(live) = guard.take() {
656            live.shutdown().await;
657        }
658        Ok(())
659    }
660
661    /// Hand the profile over to an interactive login, and stop serving until it is done.
662    ///
663    /// A profile directory supports exactly one Chrome (`SingletonLock`), so a headful
664    /// login browser cannot coexist with this session's headless one. Rather than teach
665    /// `serve` to run headful, it lets go entirely: shut the browser down, refuse to
666    /// launch another, and let the login process own the profile meanwhile. The login
667    /// writes its cookies there, and the next tool call relaunches into them.
668    pub async fn release_for_login(&self) -> Result<(), ApiError> {
669        let _activity = self.begin_browser_activity().await;
670        let mut guard = self.live.lock().await;
671        self.refuse_if_unavailable()?;
672        // Set before the teardown so a concurrent tool call cannot relaunch into the gap.
673        self.login_in_progress.store(true, Ordering::Relaxed);
674        if let Some(live) = guard.take() {
675            live.shutdown().await;
676        }
677        Ok(())
678    }
679
680    /// Start serving again after [`Session::release_for_login`], whatever the outcome.
681    ///
682    /// Deliberately does not relaunch: the browser comes back lazily on the next tool
683    /// call, which is also when a fresh login would be picked up.
684    pub async fn resume_after_login(&self) {
685        let _guard = self.live.lock().await;
686        self.login_in_progress.store(false, Ordering::Relaxed);
687    }
688
689    /// Close an idle browser generation without marking the session closed forever.
690    ///
691    /// Returns true when a live browser was shut down.
692    pub async fn close_if_idle(&self, timeout: Duration) -> Result<bool> {
693        if timeout.is_zero() || self.active_browser_ops.load(Ordering::Relaxed) != 0 {
694            return Ok(false);
695        }
696        if self.last_activity.lock().unwrap().elapsed() < timeout {
697            return Ok(false);
698        }
699        let mut guard = self.live.lock().await;
700        if self.active_browser_ops.load(Ordering::Relaxed) != 0 {
701            return Ok(false);
702        }
703        if self.last_activity.lock().unwrap().elapsed() < timeout {
704            return Ok(false);
705        }
706        if let Some(live) = guard.take() {
707            live.shutdown().await;
708            *self.last_activity.lock().unwrap() = Instant::now();
709            return Ok(true);
710        }
711        Ok(false)
712    }
713
714    /// Must be called while holding the `live` lock.
715    fn refuse_if_unavailable(&self) -> Result<(), ApiError> {
716        // Checked ahead of `closed` because `close` sets this before it even tries for the lock.
717        if self.shutting_down.load(Ordering::Relaxed) {
718            return Err(closed_underneath_us());
719        }
720        if self.closed.load(Ordering::Relaxed) {
721            return Err(closed_underneath_us());
722        }
723        if self.login_in_progress.load(Ordering::Relaxed) {
724            return Err(ApiError::InvalidRequest(
725                "a login is in progress and it owns the browser profile until it \
726                 finishes. Call login_status to see how it is going, or cancel_login to \
727                 give up, then retry."
728                    .to_string(),
729            ));
730        }
731        Ok(())
732    }
733
734    /// Relaunch against the same profile directory. The remedy for both a Cloudflare
735    /// block and a dead browser. It deliberately does not delete anything: the profile
736    /// holds a real credential, so it must survive rather than being cleared.
737    /// Replace the browser generation `blocked` -- unless someone already has.
738    /// `None` means the caller had no browser to blame, so anything currently in the
739    /// slot is by definition newer and there is nothing to do.
740    ///
741    /// Holds the lock across the whole teardown-and-relaunch so two callers cannot
742    /// interleave, and no-ops when the current browser is already newer than the
743    /// one that got blocked. Together those turn a wave of N concurrent blocked
744    /// calls into exactly one relaunch.
745    async fn relaunch(&self, blocked: Option<u64>) -> Result<(), ApiError> {
746        let mut guard = self.live.lock().await;
747        self.refuse_if_unavailable()?;
748        if !should_replace(guard.as_ref().map(|live| live.generation), blocked) {
749            return Ok(());
750        }
751        if let Some(mut dead) = guard.take() {
752            dead.browser.close().await.ok();
753            dead.browser.wait().await.ok();
754            dead.handler.abort();
755        }
756        let live = self.launch().await.map_err(ApiError::Other)?;
757        if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
758            live.shutdown().await;
759            return Err(e);
760        }
761        *guard = Some(live);
762        Ok(())
763    }
764
765    /// Call a `/kr-api/` endpoint from inside the page, retrying the two failures
766    /// that are known to be recoverable.
767    pub async fn api(
768        &self,
769        method: &str,
770        path: &str,
771        body: Option<&serde_json::Value>,
772    ) -> Result<serde_json::Value, ApiError> {
773        let _activity = self.begin_browser_activity().await;
774        let mut relaunched = false;
775        let mut refreshed_build = false;
776        let relaunch_would_hurt = relaunch_costs_a_human_their_login(self.mode);
777        loop {
778            let (generation, result) = self.attempt_once(method, path, body).await;
779            let error = match result {
780                Ok(value) => return Ok(value),
781                Err(e) => e,
782            };
783            match plan_recovery(&error, relaunched || relaunch_would_hurt, refreshed_build) {
784                Recovery::RefreshBuild(build) => {
785                    refreshed_build = true;
786                    // stderr is free -- `serve` owns stdout for JSON-RPC. Retries
787                    // are invisible otherwise, which makes a green test that never
788                    // actually exercised one indistinguishable from a real pass.
789                    eprintln!("k-ruoka-mcp: build number was stale, retrying with {build}");
790                    *self.build.lock().await = Some(build);
791                }
792                Recovery::Relaunch => {
793                    relaunched = true;
794                    eprintln!(
795                        "k-ruoka-mcp: {error}; relaunching the browser against the \
796                         same profile (never deleting it) and retrying once"
797                    );
798                    self.relaunch(generation).await?;
799                }
800                Recovery::GiveUp => return Err(error),
801            }
802        }
803    }
804
805    /// One attempt: get a page, then make the request on it.
806    ///
807    /// Returns a tuple rather than a `Result` **on purpose**. Getting the page can
808    /// fail with a Cloudflare block or a dead browser, which is where a refused page
809    /// load and a dropped transport are both detected, so this
810    /// signature is what stops a future `?` from routing that failure past the retry
811    /// loop. It has happened twice, by two different routes, both times
812    /// while the classification itself was fully unit-tested. With no `Result` to
813    /// return early from, the mistake is no longer expressible here.
814    ///
815    /// The generation is the browser incarnation the attempt used, so a block can be
816    /// attributed to it and a relaunch can no-op if someone else already replaced it.
817    /// `None` means there was no live browser to attribute the failure to -- which is
818    /// deliberately *not* a number, because every number is a real generation. It was
819    /// spelled `0` once, and since `next_generation` starts at 0 that is the first
820    /// browser: after a single relaunch, `relaunch(0)` compared 1 against 0, decided
821    /// someone else had already replaced the browser, and returned having done
822    /// nothing -- while the "relaunching" line had already gone to stderr. The retry
823    /// was then spent on the same blocked browser.
824    async fn attempt_once(
825        &self,
826        method: &str,
827        path: &str,
828        body: Option<&serde_json::Value>,
829    ) -> (Option<u64>, Result<serde_json::Value, ApiError>) {
830        match self.current_page().await {
831            Ok((page, generation)) => (
832                Some(generation),
833                self.api_once(&page, method, path, body).await,
834            ),
835            Err(e) => (None, Err(e)),
836        }
837    }
838
839    async fn api_once(
840        &self,
841        page: &Page,
842        method: &str,
843        path: &str,
844        body: Option<&serde_json::Value>,
845    ) -> Result<serde_json::Value, ApiError> {
846        let build = self.build.lock().await.clone();
847
848        // Immediately before the request leaves, so a retry is spaced from the attempt
849        // it is retrying rather than firing straight back at a server that just refused.
850        self.limiter.acquire().await;
851
852        let expr = fetch_script(method, path, body, build.as_deref());
853        let value = evaluate(page, &expr).await?;
854        let raw: RawResponse = serde_json::from_value(value)
855            .context("unexpected shape from the in-page fetch helper")
856            .map_err(ApiError::Other)?;
857
858        // Learn the build number from any response that carries it, so the very
859        // first call of a process bootstraps itself off its own 409.
860        if let Some(b) = &raw.build {
861            let mut slot = self.build.lock().await;
862            if slot.as_deref() != Some(b.as_str()) {
863                *slot = Some(b.clone());
864            }
865        }
866
867        classify(raw)
868    }
869}
870
871/// Turn a raw response into either a parsed body or a typed failure.
872///
873/// The discriminator, established empirically: Cloudflare answers with
874/// a `cf-mitigated` header or an HTML body, while the application answers with
875/// JSON even when it is refusing. A 403 with a JSON body is the app; a 409 with a
876/// JSON body is the app; an HTML body is not.
877fn classify(raw: RawResponse) -> Result<serde_json::Value, ApiError> {
878    let looks_html = raw
879        .content_type
880        .as_deref()
881        .is_some_and(|c| c.contains("text/html"))
882        || raw.body.trim_start().starts_with('<');
883
884    // "HTML body" alone is too loose a test: K-Ruoka serves an ordinary HTML 404
885    // for an unknown /kr-api/ path, and calling that a block would send us into a
886    // pointless browser relaunch. Require an actual Cloudflare fingerprint --
887    // either the header, a known challenge marker, or one of the statuses
888    // Cloudflare itself serves.
889    let challenge_marker = cloudflare_marker(&raw.body).is_some();
890    // Statuses where an *unmarked* HTML body is most likely an edge block and where
891    // relaunching is actually the right remedy. 429 is deliberately excluded: a
892    // relaunch does not fix rate limiting, and retrying straight away makes it
893    // worse, so that should surface as a plain API error instead.
894    let cf_status = matches!(raw.status, 403 | 503);
895
896    // Gated on non-2xx deliberately. A successful basket response embeds a
897    // multi-KB `productDetails` blob per item -- marketing copy, category names --
898    // and a challenge marker appearing in that free text would otherwise turn a
899    // perfectly good 200 into a bogus block plus a pointless browser relaunch.
900    let success = (200..300).contains(&raw.status);
901    if raw.cf_mitigated.is_some() || (!success && (challenge_marker || (looks_html && cf_status))) {
902        let mitigated = raw
903            .cf_mitigated
904            .as_deref()
905            .map(|m| format!(", cf-mitigated: {m}"))
906            .unwrap_or_default();
907        return Err(ApiError::Cloudflare {
908            detail: format!("API response, status {}{mitigated}", raw.status),
909        });
910    }
911
912    let message = serde_json::from_str::<ApiErrorBody>(&raw.body)
913        .ok()
914        .map(|b| b.error.message);
915
916    if (200..300).contains(&raw.status) {
917        return serde_json::from_str(&raw.body)
918            .context("K-Ruoka returned a success status with a body that is not JSON")
919            .map_err(ApiError::Other);
920    }
921
922    // Both of these are "<something> - reload" messages with near-identical shape
923    // and completely different meanings, so match on them before falling back to
924    // the status code.
925    if let Some(msg) = &message {
926        if msg.contains("Client version is too old") {
927            return Err(ApiError::StaleBuild { wanted: raw.build });
928        }
929        if msg.contains("Token renewal error") {
930            return Err(ApiError::AuthExpired);
931        }
932    }
933    if raw.status == 401 {
934        return Err(ApiError::AuthExpired);
935    }
936    Err(ApiError::Api {
937        status: raw.status,
938        message: message.unwrap_or_else(|| raw.body.chars().take(300).collect()),
939    })
940}
941
942/// Build the in-page `fetch` call.
943///
944/// Doing the request inside the page rather than from Rust is what makes it
945/// same-origin, so the browser attaches the session and Cloudflare cookies itself
946/// and we never handle them by hand.
947fn fetch_script(
948    method: &str,
949    path: &str,
950    body: Option<&serde_json::Value>,
951    build: Option<&str>,
952) -> String {
953    let body_js = match body {
954        Some(b) => format!("JSON.stringify({b})"),
955        None => "undefined".to_string(),
956    };
957    format!(
958        r#"(async () => {{
959             const headers = {{ 'Accept': 'application/json' }};
960             const build = {build};
961             if (build) headers['X-K-Build-Number'] = build;
962             const body = {body_js};
963             if (body !== undefined) headers['Content-Type'] = 'application/json';
964             const r = await fetch({path}, {{
965               method: {method},
966               headers,
967               body,
968               credentials: 'include',
969             }});
970             return {{
971               status: r.status,
972               build: r.headers.get('k-ruoka-build'),
973               cfMitigated: r.headers.get('cf-mitigated'),
974               contentType: r.headers.get('content-type'),
975               body: await r.text(),
976             }};
977           }})()"#,
978        build = serde_json::json!(build),
979        path = serde_json::json!(path),
980        method = serde_json::json!(method),
981    )
982}
983
984/// `Ws`/`ChannelSendError`/`Io` are what a Chrome that has stopped existing produces, and
985/// relaunching is the fix. `JavascriptException` and friends stay `Other`: a page bug must
986/// not trigger relaunches.
987fn cdp_error_to_api_error(what: &str, e: CdpError) -> ApiError {
988    match e {
989        CdpError::Timeout => ApiError::Other(anyhow::anyhow!("{what} timed out")),
990        CdpError::Ws(_) | CdpError::ChannelSendError(_) | CdpError::Io(_) => {
991            ApiError::BrowserGone {
992                detail: format!("{what}: {e}"),
993            }
994        }
995        other => ApiError::Other(anyhow::anyhow!("{what}: {other}")),
996    }
997}
998
999pub(crate) async fn evaluate(page: &Page, expr: &str) -> Result<serde_json::Value, ApiError> {
1000    let params = EvaluateParams::builder()
1001        .expression(expr)
1002        .await_promise(true)
1003        .return_by_value(true)
1004        .build()
1005        .map_err(|e| ApiError::Other(anyhow::anyhow!("building EvaluateParams: {e}")))?;
1006    let result = page
1007        .evaluate(params)
1008        .await
1009        .map_err(|e| cdp_error_to_api_error("page evaluation", e))?;
1010    Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
1011}
1012
1013const CLEARANCE_POLL_INTERVAL: Duration = Duration::from_millis(250);
1014
1015/// Outcome of one poll; plain data (not `Page`) so precedence below is testable without Chrome.
1016#[derive(Debug, PartialEq, Eq)]
1017enum ClearanceStep {
1018    Ready,
1019    /// Typed as `Cloudflare` so the caller relaunches instead of failing bare.
1020    Refused(&'static str),
1021    ShuttingDown,
1022    TimedOut,
1023    KeepWaiting,
1024}
1025
1026/// Precedence when several conditions hold at once: a block marker, then an arrived page,
1027/// then shutdown, then the deadline. A page that cleared is reported ready even while
1028/// shutting down, since there is nothing left to wait for.
1029fn clearance_step(
1030    origin: &str,
1031    text: Option<&str>,
1032    shutting_down: bool,
1033    past_deadline: bool,
1034) -> ClearanceStep {
1035    if let Some(marker) = text.and_then(|t| first_marker(t, BLOCK_MARKERS)) {
1036        return ClearanceStep::Refused(marker);
1037    }
1038
1039    // Not keyed on nav-label text like "Tuotteet": that ties readiness to third-party UI
1040    // copy, so a rename or A/B test would misreport as a Cloudflare block instead of just working.
1041    let challenged = text.is_some_and(|t| first_marker(t, CHALLENGE_MARKERS).is_some());
1042    if !challenged && origin == SHOP_ORIGIN && text.is_some_and(|t| !t.trim().is_empty()) {
1043        return ClearanceStep::Ready;
1044    }
1045    if shutting_down {
1046        return ClearanceStep::ShuttingDown;
1047    }
1048    if past_deadline {
1049        return ClearanceStep::TimedOut;
1050    }
1051    ClearanceStep::KeepWaiting
1052}
1053
1054/// Navigate to the shop and wait for Cloudflare, polling rather than sleeping a
1055/// fixed duration -- a fixed sleep makes failures indistinguishable from slowness.
1056async fn navigate_and_clear(page: &Page, shutting_down: &AtomicBool) -> Result<(), ApiError> {
1057    // Skip the round trip entirely if a shutdown is already requested.
1058    if shutting_down.load(Ordering::Relaxed) {
1059        return Err(closed_underneath_us());
1060    }
1061    page.goto(SHOP_URL)
1062        .await
1063        .map_err(|e| cdp_error_to_api_error(&format!("navigating to {SHOP_URL}"), e))?;
1064    let deadline = Instant::now() + CLEARANCE_TIMEOUT;
1065    loop {
1066        // Ask for the origin as well as the text: readiness is "we are on
1067        // k-ruoka.fi and nothing is blocking us", not "the SPA has finished
1068        // hydrating". The same-origin `fetch` only needs the document's origin.
1069        let probe = match evaluate(
1070            page,
1071            "({ origin: location.origin, text: document.body ? document.body.innerText : null })",
1072        )
1073        .await
1074        {
1075            Ok(v) => v,
1076            // Swallowing this polled a dead browser for the full 45s, then blamed Cloudflare.
1077            Err(e @ ApiError::BrowserGone { .. }) => return Err(e),
1078            Err(_) => serde_json::Value::Null,
1079        };
1080        let origin = probe["origin"].as_str().unwrap_or_default();
1081        let text = probe["text"].as_str();
1082
1083        match clearance_step(
1084            origin,
1085            text,
1086            shutting_down.load(Ordering::Relaxed),
1087            Instant::now() > deadline,
1088        ) {
1089            ClearanceStep::Ready => return Ok(()),
1090            ClearanceStep::Refused(marker) => {
1091                return Err(ApiError::Cloudflare {
1092                    detail: format!(
1093                        "page load rejected ({marker:?}) -- the browser fingerprint is being \
1094                         refused; a UA containing HeadlessChrome is the usual cause"
1095                    ),
1096                });
1097            }
1098            // `ApiError::Other`, not Cloudflare-shaped, so `plan_recovery` gives up instead of relaunching.
1099            ClearanceStep::ShuttingDown => return Err(closed_underneath_us()),
1100            ClearanceStep::TimedOut => {
1101                // Also bot mitigation, just quieter, so the same remedy applies.
1102                return Err(ApiError::Cloudflare {
1103                    detail: format!(
1104                        "challenge did not clear within {}s",
1105                        CLEARANCE_TIMEOUT.as_secs()
1106                    ),
1107                });
1108            }
1109            ClearanceStep::KeepWaiting => {}
1110        }
1111        tokio::time::sleep(CLEARANCE_POLL_INTERVAL).await;
1112    }
1113}
1114
1115/// Where Chrome usually lives, per platform. `K_RUOKA_CHROME` overrides.
1116///
1117/// First existing entry wins; if none exist the first is returned anyway, so the failure
1118/// names a concrete path.
1119const CHROME_CANDIDATES: &[&str] = if cfg!(target_os = "macos") {
1120    &[
1121        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1122        "/Applications/Chromium.app/Contents/MacOS/Chromium",
1123    ]
1124} else if cfg!(target_os = "windows") {
1125    &[
1126        r"C:\Program Files\Google\Chrome\Application\chrome.exe",
1127        r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
1128    ]
1129} else {
1130    &[
1131        "/usr/bin/google-chrome",
1132        "/usr/bin/google-chrome-stable",
1133        "/usr/bin/chromium",
1134        "/usr/bin/chromium-browser",
1135        "/snap/bin/chromium",
1136    ]
1137};
1138
1139fn chrome_path() -> String {
1140    if let Ok(explicit) = std::env::var("K_RUOKA_CHROME") {
1141        return explicit;
1142    }
1143    CHROME_CANDIDATES
1144        .iter()
1145        .find(|candidate| Path::new(candidate).is_file())
1146        .unwrap_or(&CHROME_CANDIDATES[0])
1147        .to_string()
1148}
1149
1150/// The installed Chrome's own UA with the `HeadlessChrome` token normalised away.
1151///
1152/// This is the *only* thing standing between us and a Cloudflare
1153/// block -- no stealth patching needed. It is derived at runtime rather than
1154/// hardcoded so it cannot drift from the actual browser at the next Chrome
1155/// update, and so headful `login` and headless `serve` present byte-identical
1156/// strings. That last part is load-bearing: `cf_clearance` is UA-bound, and the
1157/// handoff between the two modes only works because the strings match.
1158fn user_agent() -> Result<String> {
1159    // Escape hatch for when the derived string stops working (Chrome changes its
1160    // format, or Cloudflare starts wanting client hints to match). Also the only
1161    // way to deliberately provoke a block, which is how the Cloudflare recovery
1162    // path gets exercised at all.
1163    if let Ok(ua) = std::env::var("K_RUOKA_USER_AGENT") {
1164        return Ok(ua);
1165    }
1166    let version = chrome_version()?;
1167    Ok(format!(
1168        "Mozilla/5.0 ({UA_PLATFORM}) AppleWebKit/537.36 (KHTML, like Gecko) \
1169         Chrome/{version} Safari/537.36"
1170    ))
1171}
1172
1173/// The platform token real Chrome puts in its UA here.
1174///
1175/// Must match the actual OS: Chrome also sends `sec-ch-ua-platform`, which we do not
1176/// control, and UA consistency is the one thing Cloudflare cares about here.
1177/// `10_15_7` is Chrome's own frozen value on macOS, Apple Silicon included.
1178const UA_PLATFORM: &str = if cfg!(target_os = "macos") {
1179    "Macintosh; Intel Mac OS X 10_15_7"
1180} else if cfg!(target_os = "windows") {
1181    "Windows NT 10.0; Win64; x64"
1182} else {
1183    "X11; Linux x86_64"
1184};
1185
1186/// The installed Chrome's version number, e.g. `150.0.7871.181`.
1187fn chrome_version() -> Result<String> {
1188    let path = chrome_path();
1189
1190    // Not asked on Windows at all. `chrome.exe --version` there does not print a version
1191    // and does not exit, so waiting on it hung startup rather than falling through to the
1192    // directory read below.
1193    #[cfg(not(windows))]
1194    if let Some(version) = version_from_probe(&path) {
1195        return Ok(version);
1196    }
1197
1198    // Chrome keeps a version-named directory next to the executable. On Windows this is
1199    // the only way; elsewhere it is the fallback.
1200    if let Some(version) = version_from_install_dir(Path::new(&path)) {
1201        return Ok(version);
1202    }
1203
1204    anyhow::bail!(
1205        "could not determine the Chrome version from `{path} --version` or from the \
1206         install directory. Set K_RUOKA_USER_AGENT to a full User-Agent string to bypass \
1207         this, or K_RUOKA_CHROME if that path is wrong."
1208    )
1209}
1210
1211/// How long `chrome --version` gets before it is treated as unable to answer.
1212///
1213/// Bounded because an unbounded wait here is what hung Windows startup, and the same
1214/// shape is reachable elsewhere: a wrapper script that stalls, or a `K_RUOKA_CHROME`
1215/// pointing at something that reads stdin. This now runs inside `launch`, which holds the
1216/// session lock, so hanging here would also block the graceful shutdown behind it.
1217#[cfg(not(windows))]
1218const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
1219
1220/// Ask the binary for its version, giving up rather than waiting forever.
1221#[cfg(not(windows))]
1222fn version_from_probe(path: &str) -> Option<String> {
1223    // stdin is the JSON-RPC channel in `serve`; a child that reads it would consume
1224    // protocol traffic, so it gets nothing.
1225    let mut child = std::process::Command::new(path)
1226        .arg("--version")
1227        .stdin(std::process::Stdio::null())
1228        .stdout(std::process::Stdio::piped())
1229        .stderr(std::process::Stdio::null())
1230        .spawn()
1231        .ok()?;
1232
1233    let deadline = Instant::now() + VERSION_PROBE_TIMEOUT;
1234    while Instant::now() < deadline {
1235        match child.try_wait() {
1236            Ok(Some(_)) => {
1237                let mut stdout = String::new();
1238                use std::io::Read;
1239                child.stdout.take()?.read_to_string(&mut stdout).ok()?;
1240                return first_version_token(&stdout);
1241            }
1242            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1243            Err(_) => return None,
1244        }
1245    }
1246    // Still running, so it is never going to answer. Leaving it would keep a stray
1247    // process around for the life of the server.
1248    let _ = child.kill();
1249    let _ = child.wait();
1250    None
1251}
1252
1253fn first_version_token(text: &str) -> Option<String> {
1254    text.split_whitespace()
1255        .find(|token| {
1256            token.chars().next().is_some_and(|c| c.is_ascii_digit()) && token.contains('.')
1257        })
1258        .map(str::to_string)
1259}
1260
1261/// A `150.0.7871.181`-shaped sibling directory of the executable.
1262fn version_from_install_dir(exe: &Path) -> Option<String> {
1263    let dir = exe.parent()?;
1264    let mut best: Option<(Vec<u64>, String)> = None;
1265    for entry in std::fs::read_dir(dir).ok()?.flatten() {
1266        // One unreadable entry must not abandon a version already found.
1267        if !entry.file_type().is_ok_and(|t| t.is_dir()) {
1268            continue;
1269        }
1270        let name = entry.file_name().to_string_lossy().into_owned();
1271        let Some(parts) = version_parts(&name) else {
1272            continue;
1273        };
1274        // Compared as numbers, not as text: Chrome keeps the previous build until it
1275        // restarts, and 138.0.7204.97 sorts *above* 138.0.7204.183 as a string, which
1276        // would pick the older one and advertise a version that is not running.
1277        if best.as_ref().is_none_or(|(seen, _)| seen < &parts) {
1278            best = Some((parts, name));
1279        }
1280    }
1281    best.map(|(_, name)| name)
1282}
1283
1284/// Chrome's four-number scheme, e.g. `150.0.7871.181`. `None` for anything else, which
1285/// is how the executable's other siblings are skipped.
1286fn version_parts(name: &str) -> Option<Vec<u64>> {
1287    let parts: Vec<&str> = name.split('.').collect();
1288    if parts.len() != 4 {
1289        return None;
1290    }
1291    parts.iter().map(|p| p.parse::<u64>().ok()).collect()
1292}
1293
1294/// The profile holds a live login. Treat it like a credential: 0700, and refuse
1295/// to use it if it is readable by anyone else.
1296#[cfg(unix)]
1297fn ensure_private_dir(path: &Path) -> Result<()> {
1298    use std::os::unix::fs::PermissionsExt;
1299    std::fs::create_dir_all(path)
1300        .with_context(|| format!("creating profile dir {}", path.display()))?;
1301    let mut perms = std::fs::metadata(path)?.permissions();
1302    if perms.mode() & 0o077 != 0 {
1303        perms.set_mode(0o700);
1304        std::fs::set_permissions(path, perms)
1305            .with_context(|| format!("tightening permissions on {}", path.display()))?;
1306    }
1307    Ok(())
1308}
1309
1310/// No mode bits on Windows. `%LOCALAPPDATA%` is already per-user and inherits an ACL
1311/// that excludes others; the gap is that a `K_RUOKA_PROFILE` pointed somewhere
1312/// world-readable goes unwarned. Fixing that properly needs the Windows API.
1313#[cfg(windows)]
1314fn ensure_private_dir(path: &Path) -> Result<()> {
1315    std::fs::create_dir_all(path)
1316        .with_context(|| format!("creating profile dir {}", path.display()))?;
1317    Ok(())
1318}
1319
1320/// Where the login is stored, per platform convention. `K_RUOKA_PROFILE` overrides, which
1321/// is how the tests and the spike get a scratch profile instead of the real login.
1322pub fn default_profile_dir() -> Result<PathBuf> {
1323    if let Some(dir) = std::env::var_os("K_RUOKA_PROFILE") {
1324        return Ok(PathBuf::from(dir));
1325    }
1326    Ok(platform_data_dir()?.join("k-ruoka-mcp/profile"))
1327}
1328
1329/// Where the persisted default store id is written, alongside the Chrome profile directory.
1330///
1331/// For the default layout this resolves to `<platform_data_dir>/k-ruoka-mcp/default_store`,
1332/// a sibling of the `profile/` sub-directory. For a custom `K_RUOKA_PROFILE` it is a
1333/// sibling of that directory.
1334pub fn default_store_path(profile_dir: &Path) -> PathBuf {
1335    profile_dir
1336        .parent()
1337        .unwrap_or(profile_dir)
1338        .join("default_store")
1339}
1340
1341#[cfg(target_os = "linux")]
1342fn platform_data_dir() -> Result<PathBuf> {
1343    std::env::var_os("XDG_DATA_HOME")
1344        .map(PathBuf::from)
1345        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
1346        .context("neither XDG_DATA_HOME nor HOME is set")
1347}
1348
1349#[cfg(target_os = "macos")]
1350fn platform_data_dir() -> Result<PathBuf> {
1351    // Honour a deliberate XDG_DATA_HOME, else the platform convention.
1352    std::env::var_os("XDG_DATA_HOME")
1353        .map(PathBuf::from)
1354        .or_else(|| {
1355            std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
1356        })
1357        .context("HOME is not set")
1358}
1359
1360#[cfg(windows)]
1361fn platform_data_dir() -> Result<PathBuf> {
1362    std::env::var_os("LOCALAPPDATA")
1363        .map(PathBuf::from)
1364        .or_else(|| std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("AppData/Local")))
1365        .context("neither LOCALAPPDATA nor USERPROFILE is set")
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    /// Windows has nothing else to fall back on: `chrome.exe --version` is not asked
1373    /// there, so this read is the whole version lookup on that platform.
1374    #[test]
1375    fn the_chrome_version_is_read_from_a_sibling_install_directory() {
1376        let root = std::env::temp_dir().join("k-ruoka-version-dir-test");
1377        let _ = std::fs::remove_dir_all(&root);
1378        let app = root.join("Application");
1379        // 97 against 183 is the pair a string comparison gets wrong, and two builds
1380        // coexisting is the normal state until Chrome restarts after an update.
1381        for name in [
1382            "138.0.7204.183",
1383            "138.0.7204.97",
1384            "Dictionaries",
1385            "SetupMetrics",
1386        ] {
1387            std::fs::create_dir_all(app.join(name)).unwrap();
1388        }
1389        let exe = app.join("chrome.exe");
1390        std::fs::write(&exe, b"").unwrap();
1391
1392        assert_eq!(
1393            version_from_install_dir(&exe).as_deref(),
1394            Some("138.0.7204.183"),
1395            "the newest four-part directory, ignoring Chrome's other siblings"
1396        );
1397
1398        std::fs::remove_dir_all(app.join("138.0.7204.183")).unwrap();
1399        std::fs::remove_dir_all(app.join("138.0.7204.97")).unwrap();
1400        assert_eq!(
1401            version_from_install_dir(&exe),
1402            None,
1403            "no version directory is a failure to report, not a version to invent"
1404        );
1405        let _ = std::fs::remove_dir_all(&root);
1406    }
1407
1408    fn raw(status: u16, body: &str, ct: &str, cf: Option<&str>) -> RawResponse {
1409        RawResponse {
1410            status,
1411            build: Some("31844".into()),
1412            cf_mitigated: cf.map(str::to_string),
1413            content_type: Some(ct.into()),
1414            body: body.into(),
1415        }
1416    }
1417
1418    const JSON: &str = "application/json; charset=utf-8";
1419
1420    #[test]
1421    fn success_returns_parsed_body() {
1422        let v = classify(raw(200, r#"{"id":"abc"}"#, JSON, None)).unwrap();
1423        assert_eq!(v["id"], "abc");
1424    }
1425
1426    #[test]
1427    fn stale_build_is_recoverable_and_carries_the_wanted_value() {
1428        let body = r#"{"error":{"message":"Client version is too old - reload"}}"#;
1429        match classify(raw(409, body, JSON, None)) {
1430            Err(ApiError::StaleBuild { wanted }) => assert_eq!(wanted.as_deref(), Some("31844")),
1431            other => panic!("expected StaleBuild, got {other:?}"),
1432        }
1433    }
1434
1435    /// The two "- reload" messages look alike and mean opposite things: one is a
1436    /// retry, the other must never retry or touch the profile.
1437    #[test]
1438    fn token_renewal_is_auth_expiry_not_a_stale_build() {
1439        let body = r#"{"error":{"message":"Token renewal error - reload"}}"#;
1440        assert!(matches!(
1441            classify(raw(409, body, JSON, None)),
1442            Err(ApiError::AuthExpired)
1443        ));
1444        assert!(matches!(
1445            classify(raw(401, "{}", JSON, None)),
1446            Err(ApiError::AuthExpired)
1447        ));
1448    }
1449
1450    #[test]
1451    fn html_body_or_cf_header_is_a_cloudflare_block() {
1452        let html = "<!DOCTYPE html><title>Just a moment...</title>";
1453        assert!(matches!(
1454            classify(raw(403, html, "text/html", None)),
1455            Err(ApiError::Cloudflare { .. })
1456        ));
1457        // The `cf-mitigated` header alone is enough, even with a JSON body.
1458        match classify(raw(403, "{}", JSON, Some("challenge"))) {
1459            Err(ApiError::Cloudflare { detail }) => {
1460                assert!(detail.contains("cf-mitigated: challenge"), "{detail}")
1461            }
1462            other => panic!("expected Cloudflare, got {other:?}"),
1463        }
1464    }
1465
1466    /// Relaunching does not fix rate limiting, and retrying immediately makes it
1467    /// worse, so a 429 must not take the Cloudflare branch.
1468    #[test]
1469    fn html_429_is_not_treated_as_a_relaunchable_block() {
1470        let html = "<html><body>Too many requests</body></html>";
1471        assert!(matches!(
1472            classify(raw(429, html, "text/html", None)),
1473            Err(ApiError::Api { status: 429, .. })
1474        ));
1475    }
1476
1477    /// Anonymous calls to auth-only endpoints (`/kr-api/user/...`, `/kr-api/cards/getAll`,
1478    /// `/kr-api/v2/shoppinghistory`) were all observed returning 401 live, and all
1479    /// classified here -- so this branch is verified, not merely written.
1480    #[test]
1481    fn bare_401_is_auth_expiry() {
1482        assert!(matches!(
1483            classify(raw(401, "", "application/json", None)),
1484            Err(ApiError::AuthExpired)
1485        ));
1486    }
1487
1488    /// A successful basket embeds a multi-KB `productDetails` blob of marketing and
1489    /// category text per item. A challenge marker turning up in that free text must
1490    /// not turn a good 200 into a bogus block and a pointless browser relaunch.
1491    #[test]
1492    fn a_2xx_whose_body_mentions_a_challenge_marker_is_not_a_block() {
1493        let body = r#"{"id":"b1","items":[{"id":"1","name":{"finnish":"Just a moment"}}]}"#;
1494        let v = classify(raw(200, body, JSON, None)).expect("a 200 must stay a success");
1495        assert_eq!(v["id"], "b1");
1496    }
1497
1498    /// Observed live: an unknown `/kr-api/` path returns an ordinary HTML 404.
1499    /// Treating that as a block would trigger a useless browser relaunch.
1500    #[test]
1501    fn html_404_is_not_a_cloudflare_block() {
1502        let html = "<!DOCTYPE html><html><body>Not found</body></html>";
1503        assert!(matches!(
1504            classify(raw(404, html, "text/html", None)),
1505            Err(ApiError::Api { status: 404, .. })
1506        ));
1507    }
1508
1509    /// A JSON 4xx is the application refusing, and must not trigger a relaunch.
1510    #[test]
1511    fn json_error_is_an_api_error_not_a_block() {
1512        let body = r#"{"error":{"message":"Basket not found"}}"#;
1513        match classify(raw(404, body, JSON, None)) {
1514            Err(ApiError::Api { status, message }) => {
1515                assert_eq!(status, 404);
1516                assert_eq!(message, "Basket not found");
1517            }
1518            other => panic!("expected Api, got {other:?}"),
1519        }
1520    }
1521
1522    /// A challenge is transient and must be waited out during a page load, not
1523    /// treated as a refusal -- letting real Chrome clear it is the whole design.
1524    /// In an API *response* it is still a failure for that request.
1525    #[test]
1526    fn a_challenge_is_distinguished_from_a_refusal() {
1527        assert!(first_marker("Just a moment...", CHALLENGE_MARKERS).is_some());
1528        assert!(first_marker("Just a moment...", BLOCK_MARKERS).is_none());
1529
1530        assert!(first_marker("Pyyntö estetty (CF/WB)", BLOCK_MARKERS).is_some());
1531        assert!(first_marker("Pyyntö estetty (CF/WB)", CHALLENGE_MARKERS).is_none());
1532
1533        // Both are Cloudflare as far as response classification goes.
1534        assert!(cloudflare_marker("Just a moment...").is_some());
1535        assert!(cloudflare_marker("Pyyntö estetty (CF/WB)").is_some());
1536        // The real shop page is neither.
1537        assert!(cloudflare_marker("Tuotteet Kaupat Reseptit Ostoskori").is_none());
1538    }
1539
1540    fn cf() -> ApiError {
1541        ApiError::Cloudflare {
1542            detail: "blocked".into(),
1543        }
1544    }
1545
1546    fn browser_gone() -> ApiError {
1547        ApiError::BrowserGone {
1548            detail: "connection reset".into(),
1549        }
1550    }
1551
1552    /// A Cloudflare block gets exactly one relaunch, from either failure site.
1553    #[test]
1554    fn a_cloudflare_block_is_relaunched_once_then_given_up_on() {
1555        assert_eq!(plan_recovery(&cf(), false, false), Recovery::Relaunch);
1556        assert_eq!(plan_recovery(&cf(), true, false), Recovery::GiveUp);
1557    }
1558
1559    /// A dead browser gets the same one-shot relaunch budget as a Cloudflare block.
1560    #[test]
1561    fn a_dead_browser_is_relaunched_once_then_given_up_on() {
1562        assert_eq!(
1563            plan_recovery(&browser_gone(), false, false),
1564            Recovery::Relaunch
1565        );
1566        assert_eq!(
1567            plan_recovery(&browser_gone(), true, false),
1568            Recovery::GiveUp
1569        );
1570    }
1571
1572    /// A killed Chrome must reach the retryable variant, not the catch-all.
1573    #[test]
1574    fn a_dead_transport_is_reported_as_browser_gone() {
1575        let io_err = CdpError::Io(std::io::Error::other("connection reset"));
1576        assert!(matches!(
1577            cdp_error_to_api_error("page evaluation", io_err),
1578            ApiError::BrowserGone { .. }
1579        ));
1580    }
1581
1582    /// The literal `oneshot canceled` a user sees when Chrome dies mid-call.
1583    #[tokio::test]
1584    async fn a_canceled_response_channel_is_reported_as_browser_gone() {
1585        let (tx, rx) = futures::channel::oneshot::channel::<()>();
1586        drop(tx);
1587        let canceled = rx.await.unwrap_err();
1588        let e = CdpError::ChannelSendError(chromiumoxide::error::ChannelError::Canceled(canceled));
1589        assert!(matches!(
1590            cdp_error_to_api_error("page evaluation", e),
1591            ApiError::BrowserGone { .. }
1592        ));
1593    }
1594
1595    /// A script bug must surface, not become an infinite relaunch loop.
1596    #[test]
1597    fn a_non_transport_cdp_error_stays_the_opaque_other() {
1598        assert!(matches!(
1599            cdp_error_to_api_error("page evaluation", CdpError::NotFound),
1600            ApiError::Other(_)
1601        ));
1602        assert!(matches!(
1603            cdp_error_to_api_error("page evaluation", CdpError::Timeout),
1604            ApiError::Other(_)
1605        ));
1606    }
1607
1608    #[test]
1609    fn a_stale_build_is_refreshed_once_then_given_up_on() {
1610        let stale = ApiError::StaleBuild {
1611            wanted: Some("31844".into()),
1612        };
1613        assert_eq!(
1614            plan_recovery(&stale, false, false),
1615            Recovery::RefreshBuild("31844".into())
1616        );
1617        assert_eq!(plan_recovery(&stale, false, true), Recovery::GiveUp);
1618    }
1619
1620    /// Cold start sends no build header at all, which is the common way the
1621    /// stale-build retry actually fires -- not a K-Ruoka deploy. Without a value
1622    /// there is nothing to heal with, and writing `None` through would clobber a
1623    /// known-good build number for the rest of the process.
1624    #[test]
1625    fn a_stale_build_carrying_no_value_is_not_retryable() {
1626        let e = ApiError::StaleBuild { wanted: None };
1627        assert_eq!(plan_recovery(&e, false, false), Recovery::GiveUp);
1628    }
1629
1630    /// The first request must not be delayed -- a person waiting on a cart read should
1631    /// not pay for a limit that exists to stop bursts.
1632    #[tokio::test]
1633    async fn the_first_request_is_not_delayed() {
1634        let limiter = RateLimiter::new(Duration::from_millis(500));
1635        let start = Instant::now();
1636        limiter.acquire().await;
1637        assert!(
1638            start.elapsed() < Duration::from_millis(100),
1639            "{:?}",
1640            start.elapsed()
1641        );
1642    }
1643
1644    /// The property that matters: concurrent callers queue instead of firing together.
1645    /// rmcp dispatches tool calls in parallel, so this is the realistic shape.
1646    #[tokio::test]
1647    async fn concurrent_callers_are_spaced_out_not_batched() {
1648        let limiter = std::sync::Arc::new(RateLimiter::new(Duration::from_millis(50)));
1649        let start = Instant::now();
1650        let mut handles = Vec::new();
1651        for _ in 0..4 {
1652            let limiter = std::sync::Arc::clone(&limiter);
1653            handles.push(tokio::spawn(async move { limiter.acquire().await }));
1654        }
1655        for handle in handles {
1656            handle.await.unwrap();
1657        }
1658        // Four requests, three gaps: the last cannot have gone out before 150 ms.
1659        assert!(
1660            start.elapsed() >= Duration::from_millis(150),
1661            "went out as a burst: {:?}",
1662            start.elapsed()
1663        );
1664    }
1665
1666    /// Zero has to mean off rather than "sleep for zero", so the live suites can opt out.
1667    #[tokio::test]
1668    async fn a_zero_interval_disables_the_limiter() {
1669        let limiter = RateLimiter::new(Duration::ZERO);
1670        let start = Instant::now();
1671        for _ in 0..20 {
1672            limiter.acquire().await;
1673        }
1674        assert!(
1675            start.elapsed() < Duration::from_millis(100),
1676            "{:?}",
1677            start.elapsed()
1678        );
1679    }
1680
1681    #[test]
1682    fn a_shutdown_request_interrupts_a_still_open_challenge() {
1683        assert_eq!(
1684            clearance_step("https://example.com", Some("Just a moment..."), true, false),
1685            ClearanceStep::ShuttingDown,
1686            "without this, a shutdown mid-challenge would fall through to KeepWaiting \
1687             and hold the live lock for up to CLEARANCE_TIMEOUT"
1688        );
1689    }
1690
1691    #[test]
1692    fn shutting_down_does_not_override_an_already_cleared_page() {
1693        assert_eq!(
1694            clearance_step(SHOP_ORIGIN, Some("Tervetuloa K-Ruokaan"), true, false),
1695            ClearanceStep::Ready
1696        );
1697    }
1698
1699    #[test]
1700    fn a_block_marker_still_refuses_during_a_shutdown() {
1701        assert_eq!(
1702            clearance_step(
1703                "https://example.com",
1704                Some("Attention Required"),
1705                true,
1706                false
1707            ),
1708            ClearanceStep::Refused("Attention Required")
1709        );
1710    }
1711
1712    #[test]
1713    fn a_past_deadline_still_times_out_without_a_shutdown_request() {
1714        assert_eq!(
1715            clearance_step("https://example.com", Some("Just a moment..."), false, true),
1716            ClearanceStep::TimedOut
1717        );
1718    }
1719
1720    #[test]
1721    fn keeps_waiting_when_nothing_terminal_has_happened() {
1722        assert_eq!(
1723            clearance_step(
1724                "https://example.com",
1725                Some("Just a moment..."),
1726                false,
1727                false
1728            ),
1729            ClearanceStep::KeepWaiting
1730        );
1731    }
1732
1733    /// Needs no Chrome: the lock is held by the test itself, standing in for a poll mid-challenge.
1734    #[tokio::test]
1735    async fn close_signals_shutdown_before_it_can_get_the_lock() {
1736        let profile =
1737            std::env::temp_dir().join(format!("k-ruoka-shutdown-flag-test-{}", std::process::id()));
1738        let _ = std::fs::remove_dir_all(&profile);
1739        let session = std::sync::Arc::new(Session::new(&profile, LaunchMode::Headless).unwrap());
1740
1741        let guard = session.live.lock().await;
1742        let closing = {
1743            let session = std::sync::Arc::clone(&session);
1744            tokio::spawn(async move { session.close().await })
1745        };
1746
1747        let deadline = Instant::now() + Duration::from_secs(5);
1748        while !session.shutting_down.load(Ordering::Relaxed) {
1749            assert!(
1750                Instant::now() < deadline,
1751                "close did not set shutting_down while this test still held the live lock"
1752            );
1753            tokio::time::sleep(Duration::from_millis(5)).await;
1754        }
1755
1756        drop(guard);
1757        closing.await.unwrap().unwrap();
1758        let _ = std::fs::remove_dir_all(&profile);
1759    }
1760
1761    #[tokio::test]
1762    async fn idle_close_is_a_noop_without_a_live_browser() {
1763        let profile =
1764            std::env::temp_dir().join(format!("k-ruoka-idle-close-test-{}", std::process::id()));
1765        let _ = std::fs::remove_dir_all(&profile);
1766        let session = Session::new(&profile, LaunchMode::Headless).unwrap();
1767
1768        tokio::time::sleep(Duration::from_millis(5)).await;
1769        assert!(
1770            !session
1771                .close_if_idle(Duration::from_millis(1))
1772                .await
1773                .unwrap()
1774        );
1775
1776        let _ = std::fs::remove_dir_all(&profile);
1777    }
1778
1779    #[tokio::test]
1780    async fn idle_close_waits_while_an_operation_is_in_flight() {
1781        let profile =
1782            std::env::temp_dir().join(format!("k-ruoka-idle-activity-test-{}", std::process::id()));
1783        let _ = std::fs::remove_dir_all(&profile);
1784        let session = Session::new(&profile, LaunchMode::Headless).unwrap();
1785
1786        let activity = session.begin_browser_activity().await;
1787        tokio::time::sleep(Duration::from_millis(5)).await;
1788        assert!(
1789            !session
1790                .close_if_idle(Duration::from_millis(1))
1791                .await
1792                .unwrap()
1793        );
1794        drop(activity);
1795
1796        let _ = std::fs::remove_dir_all(&profile);
1797    }
1798
1799    /// `serve` must be able to recover from a block; `login` must not, because the
1800    /// browser is the window someone is typing a password into.
1801    #[test]
1802    fn only_headless_may_relaunch_out_from_under_the_browser() {
1803        assert!(!relaunch_costs_a_human_their_login(LaunchMode::Headless));
1804        assert!(relaunch_costs_a_human_their_login(LaunchMode::Headful {
1805            debug_port: 9222
1806        }));
1807
1808        // And the flag really does suppress the relaunch once set.
1809        let block = ApiError::Cloudflare {
1810            detail: "page load rejected".into(),
1811        };
1812        assert_eq!(plan_recovery(&block, false, false), Recovery::Relaunch);
1813        assert_eq!(plan_recovery(&block, true, false), Recovery::GiveUp);
1814    }
1815
1816    /// The de-duplication that turns a wave of N blocked calls into one relaunch.
1817    #[test]
1818    fn only_the_browser_that_got_blocked_is_replaced() {
1819        // The blocked browser is still live: this caller is the one that should act.
1820        assert!(should_replace(Some(7), Some(7)));
1821        // Someone else already replaced it, so doing it again would tear down the
1822        // browser they just built. This is the whole point of the generation.
1823        assert!(!should_replace(Some(8), Some(7)));
1824        // Nothing live: launch, whoever was blocked.
1825        assert!(should_replace(None, Some(7)));
1826        assert!(should_replace(None, None));
1827    }
1828
1829    /// The regression this signature exists to prevent, pinned.
1830    ///
1831    /// "No browser to blame" used to be spelled `0`. Since generations start at 0 that
1832    /// is the *first* browser, so after a single relaunch the comparison was 1 against
1833    /// 0 -- read as "someone else already replaced it" -- and the relaunch silently
1834    /// did nothing, having already announced itself on stderr. The retry was then
1835    /// spent against the same blocked browser.
1836    ///
1837    /// With `Option` the two cases cannot be confused: generation 0 is a browser,
1838    /// `None` is the absence of one, and they behave differently here.
1839    #[test]
1840    fn no_browser_to_blame_is_not_the_same_as_generation_zero() {
1841        // The old sentinel, read as a real generation: correctly declines, because
1842        // generation 0 really has been superseded by 1.
1843        assert!(!should_replace(Some(1), Some(0)));
1844        // What that case actually meant. Nothing live is the state `ensure_live` now
1845        // leaves behind when navigation is blocked, so the relaunch does happen.
1846        assert!(should_replace(None, None));
1847        // And generation 0 is an ordinary generation in every other respect.
1848        assert!(should_replace(Some(0), Some(0)));
1849    }
1850
1851    /// Retrying cannot fix an expired session, and the profile must not be touched
1852    /// over one. This is the distinction the whole error enum exists for.
1853    #[test]
1854    fn auth_expiry_and_plain_api_errors_are_never_retried() {
1855        assert_eq!(
1856            plan_recovery(&ApiError::AuthExpired, false, false),
1857            Recovery::GiveUp
1858        );
1859        assert_eq!(
1860            plan_recovery(
1861                &ApiError::Api {
1862                    status: 404,
1863                    message: "nope".into()
1864                },
1865                false,
1866                false
1867            ),
1868            Recovery::GiveUp
1869        );
1870        assert_eq!(
1871            plan_recovery(&ApiError::InvalidRequest("bad".into()), false, false),
1872            Recovery::GiveUp
1873        );
1874    }
1875
1876    #[test]
1877    fn fetch_script_escapes_its_inputs() {
1878        let s = fetch_script("POST", "/kr-api/basket/active", None, Some("31844"));
1879        assert!(s.contains(r#"const build = "31844""#));
1880        assert!(s.contains(r#"fetch("/kr-api/basket/active""#));
1881        assert!(s.contains("const body = undefined"));
1882    }
1883
1884    /// The body is interpolated into JS source too, and `item_id` reaches it from
1885    /// caller-supplied input. A quote or backslash must not be able to break out of
1886    /// the string and become code.
1887    #[test]
1888    fn fetch_script_escapes_the_body() {
1889        let hostile = r#"a"); alert('x'); //\"#;
1890        let body = serde_json::json!([{ "type": "REMOVE-ITEM", "itemId": hostile }]);
1891        let s = fetch_script("PATCH", "/kr-api/basket/by-id/1", Some(&body), None);
1892
1893        // The dangerous characters survive only in escaped form.
1894        assert!(
1895            !s.contains(r#"itemId":"a");"#),
1896            "raw injection present:\n{s}"
1897        );
1898        assert!(
1899            s.contains(r#"\"); alert('x'); //\\"#),
1900            "not escaped as expected:\n{s}"
1901        );
1902
1903        // And the escaped form round-trips back to the original through JSON.
1904        let line = s
1905            .lines()
1906            .find(|l| l.contains("JSON.stringify("))
1907            .expect("the body line");
1908        let json = line
1909            .trim()
1910            .trim_start_matches("const body = JSON.stringify(")
1911            .trim_end_matches(';')
1912            .trim_end_matches(')');
1913        let parsed: serde_json::Value = serde_json::from_str(json).expect("valid JSON literal");
1914        assert_eq!(parsed[0]["itemId"], hostile);
1915    }
1916
1917    /// The lookalikes start with `SHOP_ORIGIN`, so a prefix match accepts every one.
1918    #[test]
1919    fn only_urls_actually_on_the_shop_origin_pass() {
1920        let cases: &[(&str, bool)] = &[
1921            ("https://www.k-ruoka.fi", true),
1922            (SHOP_URL, true),
1923            ("https://www.k-ruoka.fi/kauppa?x=1", true),
1924            ("https://www.k-ruoka.fi/kauppa#section", true),
1925            ("https://www.k-ruoka.fi.example.net/kauppa", false),
1926            ("https://www.k-ruoka.film/", false),
1927            ("https://www.k-ruoka.fi:9222/", false),
1928            ("https://www.k-ruoka.fi@evil.example/kauppa", false),
1929            (
1930                "https://login.kesko.fi/?redirect=https://www.k-ruoka.fi",
1931                false,
1932            ),
1933            ("http://www.k-ruoka.fi", false),
1934        ];
1935        for (url, expected) in cases {
1936            assert_eq!(on_shop_origin(url), *expected, "url: {url}");
1937        }
1938    }
1939}