diff --git a/CLAUDE.md b/CLAUDE.md index dcc3e4c..51b28e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ reach is an AI-drivable containerized desktop sandbox. It provides three things: 2. A Rust CLI (`reach`) that manages sandbox containers from the host 3. An MCP server (`reach serve`) that exposes sandbox tools to AI agents via SSE -Current phase: **Phase 1 -- container desktop (complete), entering Phase 2**. All 8 CLI commands are implemented, the type system is designed (5 layers, 1197 lines), all 8 MCP tools are implemented, and e2e tests pass (88 tests: 52 unit + 36 e2e). +Current phase: **Phase 1 -- container desktop (complete), entering Phase 2**. All 8 CLI commands are implemented, the type system is designed (5 layers, 1197 lines), 10 MCP tools are implemented (including `page_text` and `auth_handoff` for JS-heavy SPAs and login handoffs), and e2e tests pass (Phase 1: 88 tests, +3 e2e tests for the new tools). ## Tech Stack @@ -106,3 +106,8 @@ GitHub Actions workflows in `.github/workflows/`: 6. Configuration loading is in `crates/reach-cli/src/config.rs`. 7. When adding a new CLI command, add the variant to `commands/mod.rs` and create the corresponding module. 8. When adding a new supervised process, add it to `processes.rs` in reach-supervisor. +9. Python helpers that run inside the container should be embedded as `pub const` strings in `docker.rs` (see `PAGE_TEXT_SCRIPT` / `AUTH_HANDOFF_SCRIPT`) so the binary stays self-contained. + +## Persistent Chrome Profiles + +`reach create --persist-profile ` mounts `~/.local/share/reach/profiles/` (host) into the container at `/home/sandbox/.config/google-chrome-profiles/`. The host root is overridable via `sandbox.profile_dir` in `~/.config/reach/config.toml`. Pass the same profile name to `page_text` / `auth_handoff` via `use_profile` so a one-time login carries across sandbox restarts. diff --git a/crates/reach-cli/src/commands/connect.rs b/crates/reach-cli/src/commands/connect.rs index 2e284a3..4178807 100644 --- a/crates/reach-cli/src/commands/connect.rs +++ b/crates/reach-cli/src/commands/connect.rs @@ -1,5 +1,7 @@ use clap::Args; -use reach_cli::docker::DockerClient; +use reach_cli::docker::{ + AuthHandoffOptions, DockerClient, PageTextOptions, ProfileMount, novnc_url, +}; use reach_cli::mcp::{ JsonRpcRequest, JsonRpcResponse, McpInitializeResult, RequestId, ToolResponse, tool_definitions, }; @@ -176,6 +178,115 @@ async fn dispatch_tool( .unwrap_or("echo"); exec_cmd(docker, target, cmd).await } + "page_text" => { + let url = match args.get("url").and_then(|v| v.as_str()) { + Some(u) if !u.is_empty() => u.to_string(), + _ => { + return JsonRpcResponse::success( + req.id.clone(), + serde_json::to_value(ToolResponse::error( + "page_text: missing required `url`", + )) + .unwrap(), + ); + } + }; + let opts = PageTextOptions { + url, + wait_for: args + .get("wait_for") + .and_then(|v| v.as_str()) + .map(str::to_string), + selector: args + .get("selector") + .and_then(|v| v.as_str()) + .map(str::to_string), + timeout_ms: args + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(30_000), + user_data_dir: args + .get("use_profile") + .and_then(|v| v.as_str()) + .map(ProfileMount::container_path_for), + }; + match docker.page_text(target, &opts).await { + Ok(out) => match serde_json::to_string_pretty(&out) { + Ok(s) => ToolResponse::text(s), + Err(e) => ToolResponse::error(e.to_string()), + }, + Err(e) => ToolResponse::error(e.to_string()), + } + } + "auth_handoff" => { + let url = match args.get("url").and_then(|v| v.as_str()) { + Some(u) if !u.is_empty() => u.to_string(), + _ => { + return JsonRpcResponse::success( + req.id.clone(), + serde_json::to_value(ToolResponse::error( + "auth_handoff: missing required `url`", + )) + .unwrap(), + ); + } + }; + let opts = AuthHandoffOptions { + url, + wait_for_selector: args + .get("wait_for_selector") + .and_then(|v| v.as_str()) + .map(str::to_string), + wait_for_url_contains: args + .get("wait_for_url_contains") + .and_then(|v| v.as_str()) + .map(str::to_string), + timeout_seconds: args + .get("timeout_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(300), + user_data_dir: args + .get("use_profile") + .and_then(|v| v.as_str()) + .map(ProfileMount::container_path_for), + }; + + let vnc = match docker.find(target).await { + Ok(sandbox) => sandbox + .ports + .novnc + .map(|p| novnc_url("localhost", p)) + .unwrap_or_else(|| novnc_url("localhost", 6080)), + Err(_) => novnc_url("localhost", 6080), + }; + + match docker.auth_handoff(target, &opts).await { + Ok(out) => { + let body = serde_json::json!({ + "status": out.status, + "vnc_url": vnc, + "url": out.url, + "message": out.message, + "instructions": "Open the vnc_url in your browser to log in. Re-call \ + `auth_handoff` (with wait_for_*) or `page_text` once done.", + }); + match serde_json::to_string_pretty(&body) { + Ok(s) => ToolResponse::text(s), + Err(e) => ToolResponse::error(e.to_string()), + } + } + Err(e) => { + let body = serde_json::json!({ + "status": "error", + "vnc_url": vnc, + "message": e.to_string(), + }); + ToolResponse::error( + serde_json::to_string_pretty(&body).unwrap_or_else(|_| e.to_string()), + ) + } + } + } _ => ToolResponse::error(format!("unknown tool: {tool}")), }; diff --git a/crates/reach-cli/src/commands/create.rs b/crates/reach-cli/src/commands/create.rs index 2c09b29..f2d5370 100644 --- a/crates/reach-cli/src/commands/create.rs +++ b/crates/reach-cli/src/commands/create.rs @@ -1,7 +1,7 @@ use clap::Args; use colored::Colorize; use reach_cli::config::ReachConfig; -use reach_cli::docker::{DockerClient, Resolution, SandboxConfig, SandboxPorts}; +use reach_cli::docker::{DockerClient, ProfileMount, Resolution, SandboxConfig, SandboxPorts}; use std::time::Duration; #[derive(Args)] @@ -33,15 +33,35 @@ pub struct CreateArgs { /// Skip waiting for health check #[arg(long)] pub no_wait: bool, + + /// Persist a Chrome profile across sandbox restarts. + /// + /// The named profile is stored on the host under + /// `~/.local/share/reach/profiles/` (overridable via the + /// `sandbox.profile_dir` config key) and bind-mounted into the + /// container at `/home/sandbox/.config/google-chrome-profiles/`. + /// Pass the same name to `page_text` / `auth_handoff` via + /// `use_profile` to reuse the session. + #[arg(long, value_name = "NAME")] + pub persist_profile: Option, } pub async fn run(args: CreateArgs) -> anyhow::Result<()> { let cfg = ReachConfig::load(); let resolution = Resolution::parse(&args.resolution)?; + let profile = args.persist_profile.as_ref().map(|name| { + let host_path = ProfileMount::host_path_for(&cfg.sandbox.resolved_profile_dir(), name); + ProfileMount { + name: name.clone(), + host_path, + container_path: ProfileMount::container_path_for(name), + } + }); + let config = SandboxConfig { name: args.name.clone(), - image: args.image.unwrap_or(cfg.sandbox.image), + image: args.image.unwrap_or(cfg.sandbox.image.clone()), resolution, shm_size: cfg.sandbox.shm_size, ports: SandboxPorts { @@ -49,6 +69,7 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> { novnc: args.novnc_port.unwrap_or(cfg.sandbox.novnc_port), health: args.health_port.unwrap_or(cfg.sandbox.health_port), }, + profile, }; let docker = DockerClient::new()?; @@ -76,6 +97,17 @@ pub async fn run(args: CreateArgs) -> anyhow::Result<()> { args.resolution ); + if let Some(name) = &args.persist_profile { + let host = ProfileMount::host_path_for(&cfg.sandbox.resolved_profile_dir(), name); + println!( + " {} {} {} {}", + "\u{2713}".green(), + "Profile ".dimmed(), + name, + format!("({})", host.display()).dimmed() + ); + } + if !args.no_wait { print!(" \u{2819} {}", "Waiting for health...".dimmed()); docker diff --git a/crates/reach-cli/src/commands/serve.rs b/crates/reach-cli/src/commands/serve.rs index e076841..6e213fe 100644 --- a/crates/reach-cli/src/commands/serve.rs +++ b/crates/reach-cli/src/commands/serve.rs @@ -3,7 +3,9 @@ use axum::response::sse::{Event, Sse}; use axum::routing::{get, post}; use axum::{Json, Router}; use clap::Args; -use reach_cli::docker::DockerClient; +use reach_cli::docker::{ + AuthHandoffOptions, DockerClient, PageTextOptions, ProfileMount, novnc_url, +}; use reach_cli::mcp::{ JsonRpcRequest, JsonRpcResponse, McpInitializeResult, ToolResponse, tool_definitions, }; @@ -221,6 +223,101 @@ async fn dispatch( .unwrap_or("echo"); sh(state, target, cmd).await } + "page_text" => { + let url = match args.get("url").and_then(|v| v.as_str()) { + Some(u) if !u.is_empty() => u.to_string(), + _ => return ToolResponse::error("page_text: missing required `url`"), + }; + let opts = PageTextOptions { + url, + wait_for: args + .get("wait_for") + .and_then(|v| v.as_str()) + .map(str::to_string), + selector: args + .get("selector") + .and_then(|v| v.as_str()) + .map(str::to_string), + timeout_ms: args + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(30_000), + user_data_dir: args + .get("use_profile") + .and_then(|v| v.as_str()) + .map(ProfileMount::container_path_for), + }; + match state.docker.page_text(target, &opts).await { + Ok(out) => match serde_json::to_string_pretty(&out) { + Ok(s) => ToolResponse::text(s), + Err(e) => ToolResponse::error(e.to_string()), + }, + Err(e) => ToolResponse::error(e.to_string()), + } + } + "auth_handoff" => { + let url = match args.get("url").and_then(|v| v.as_str()) { + Some(u) if !u.is_empty() => u.to_string(), + _ => return ToolResponse::error("auth_handoff: missing required `url`"), + }; + let opts = AuthHandoffOptions { + url, + wait_for_selector: args + .get("wait_for_selector") + .and_then(|v| v.as_str()) + .map(str::to_string), + wait_for_url_contains: args + .get("wait_for_url_contains") + .and_then(|v| v.as_str()) + .map(str::to_string), + timeout_seconds: args + .get("timeout_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(300), + user_data_dir: args + .get("use_profile") + .and_then(|v| v.as_str()) + .map(ProfileMount::container_path_for), + }; + + // Resolve the noVNC URL up-front so we can include it in the + // response no matter which branch the helper takes. + let vnc = match state.docker.find(target).await { + Ok(sandbox) => sandbox + .ports + .novnc + .map(|p| novnc_url("localhost", p)) + .unwrap_or_else(|| novnc_url("localhost", 6080)), + Err(_) => novnc_url("localhost", 6080), + }; + + match state.docker.auth_handoff(target, &opts).await { + Ok(out) => { + let body = serde_json::json!({ + "status": out.status, + "vnc_url": vnc, + "url": out.url, + "message": out.message, + "instructions": "Open the vnc_url in your browser to log in. Re-call \ + `auth_handoff` (with wait_for_*) or `page_text` once done.", + }); + match serde_json::to_string_pretty(&body) { + Ok(s) => ToolResponse::text(s), + Err(e) => ToolResponse::error(e.to_string()), + } + } + Err(e) => { + let body = serde_json::json!({ + "status": "error", + "vnc_url": vnc, + "message": e.to_string(), + }); + ToolResponse::error( + serde_json::to_string_pretty(&body).unwrap_or_else(|_| e.to_string()), + ) + } + } + } _ => ToolResponse::error(format!("unknown tool: {tool}")), } } diff --git a/crates/reach-cli/src/config.rs b/crates/reach-cli/src/config.rs index fdd00c5..17e383b 100644 --- a/crates/reach-cli/src/config.rs +++ b/crates/reach-cli/src/config.rs @@ -29,6 +29,34 @@ pub struct SandboxDefaults { pub novnc_port: u16, /// Default health API port pub health_port: u16, + /// Root directory for persistent Chrome profiles on the host. + /// + /// Each `--persist-profile ` is materialised as a subdirectory + /// under this path. `None` means use the platform default + /// (`~/.local/share/reach/profiles`). + #[serde(default)] + pub profile_dir: Option, +} + +impl SandboxDefaults { + /// Resolve the directory used to store persistent Chrome profiles. + /// + /// Falls back to `$XDG_DATA_HOME/reach/profiles` (or + /// `~/.local/share/reach/profiles`) when `profile_dir` is unset. + pub fn resolved_profile_dir(&self) -> PathBuf { + self.profile_dir.clone().unwrap_or_else(default_profile_dir) + } +} + +/// Platform default for the persistent Chrome profile root. +pub fn default_profile_dir() -> PathBuf { + let base = std::env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".local").join("share") + }); + base.join("reach").join("profiles") } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,6 +89,7 @@ impl Default for SandboxDefaults { vnc_port: 5900, novnc_port: 6080, health_port: 8400, + profile_dir: None, } } } @@ -103,3 +132,33 @@ fn dirs() -> PathBuf { }); base.join("reach") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_profile_dir_uses_explicit_override() { + let defaults = SandboxDefaults { + profile_dir: Some(PathBuf::from("/tmp/custom/profiles")), + ..SandboxDefaults::default() + }; + assert_eq!( + defaults.resolved_profile_dir(), + PathBuf::from("/tmp/custom/profiles") + ); + } + + #[test] + fn resolved_profile_dir_falls_back_to_default() { + let defaults = SandboxDefaults::default(); + let resolved = defaults.resolved_profile_dir(); + assert!(resolved.ends_with("reach/profiles")); + } + + #[test] + fn default_profile_dir_contains_reach_segment() { + let dir = default_profile_dir(); + assert!(dir.to_string_lossy().contains("reach")); + } +} diff --git a/crates/reach-cli/src/docker.rs b/crates/reach-cli/src/docker.rs index 94c121b..137ec50 100644 --- a/crates/reach-cli/src/docker.rs +++ b/crates/reach-cli/src/docker.rs @@ -4,9 +4,10 @@ use bollard::container::{ StopContainerOptions, }; use bollard::exec::{CreateExecOptions, StartExecResults}; -use bollard::models::{HostConfig, PortBinding}; +use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding}; use futures::StreamExt; use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration; // ═══════════════════════════════════════════════════════════ @@ -20,6 +21,38 @@ pub struct SandboxConfig { pub resolution: Resolution, pub shm_size: u64, pub ports: SandboxPorts, + /// Optional persistent Chrome profile mount. + pub profile: Option, +} + +/// Bind mount that backs a persistent Chrome profile. +/// +/// `host_path` is created on the host (if missing) and mounted into the +/// container at `container_path`. `name` is propagated as the +/// `reach.profile` label so that `reach list` and downstream tools can +/// discover the profile attached to a sandbox. +#[derive(Debug, Clone)] +pub struct ProfileMount { + pub name: String, + pub host_path: PathBuf, + pub container_path: String, +} + +impl ProfileMount { + /// Container path used for a profile of the given `name`. + /// + /// All persistent profiles live under + /// `/home/sandbox/.config/google-chrome-profiles/` in the + /// container so the path is stable across sandboxes. + pub fn container_path_for(name: &str) -> String { + format!("/home/sandbox/.config/google-chrome-profiles/{name}") + } + + /// Host path used for a profile of the given `name`, rooted at + /// `base_dir` (typically `~/.local/share/reach/profiles`). + pub fn host_path_for(base_dir: &std::path::Path, name: &str) -> PathBuf { + base_dir.join(name) + } } #[derive(Debug, Clone)] @@ -73,6 +106,7 @@ impl Default for SandboxConfig { }, shm_size: 2 * 1024 * 1024 * 1024, ports: SandboxPorts::default(), + profile: None, } } } @@ -137,6 +171,7 @@ impl Labels { pub const NAME: &str = "reach.name"; pub const CREATED: &str = "reach.created"; pub const RESOLUTION: &str = "reach.resolution"; + pub const PROFILE: &str = "reach.profile"; pub fn for_sandbox(config: &SandboxConfig) -> HashMap { let mut labels = HashMap::new(); @@ -144,6 +179,9 @@ impl Labels { labels.insert(Self::NAME.into(), config.name.clone()); labels.insert(Self::CREATED.into(), chrono::Utc::now().to_rfc3339()); labels.insert(Self::RESOLUTION.into(), config.resolution.to_string()); + if let Some(profile) = &config.profile { + labels.insert(Self::PROFILE.into(), profile.name.clone()); + } labels } @@ -201,9 +239,29 @@ impl DockerClient { map }; + let mounts = if let Some(profile) = &config.profile { + // Ensure the host directory exists so the bind mount succeeds. + std::fs::create_dir_all(&profile.host_path).with_context(|| { + format!( + "failed to create profile dir {}", + profile.host_path.display() + ) + })?; + Some(vec![Mount { + target: Some(profile.container_path.clone()), + source: Some(profile.host_path.to_string_lossy().into_owned()), + typ: Some(MountTypeEnum::BIND), + read_only: Some(false), + ..Default::default() + }]) + } else { + None + }; + let host_config = HostConfig { port_bindings: Some(port_bindings), shm_size: Some(config.shm_size as i64), + mounts, ..Default::default() }; @@ -398,6 +456,103 @@ impl DockerClient { Ok(bytes) } + /// Run a Playwright-driven "navigate and extract text" script in the + /// sandbox. + /// + /// The Python helper launches headed Chromium on Xvfb (so the page is + /// visible through noVNC) and prints a single JSON object on stdout. + pub async fn page_text(&self, target: &str, opts: &PageTextOptions) -> Result { + let payload = serde_json::json!({ + "url": opts.url, + "wait_for": opts.wait_for, + "selector": opts.selector, + "timeout_ms": opts.timeout_ms, + "user_data_dir": opts.user_data_dir, + }); + + let payload_str = + serde_json::to_string(&payload).context("failed to serialize page_text payload")?; + + // Pass the JSON via stdin-style env var to dodge shell quoting hell. + let cmd = format!( + "REACH_PAGE_TEXT_PAYLOAD={} python3 -c {}", + shell_single_quote(&payload_str), + shell_single_quote(PAGE_TEXT_SCRIPT), + ); + + let out = self + .exec(target, &["bash".into(), "-c".into(), cmd]) + .await?; + + if out.exit_code != 0 { + // Even with a non-zero exit, the script may have produced JSON. + if let Some(parsed) = parse_page_text_json(&out.stdout) { + return Ok(parsed); + } + bail!( + "page_text exec failed (exit {}): {}", + out.exit_code, + out.stderr + ); + } + + parse_page_text_json(&out.stdout).ok_or_else(|| { + anyhow::anyhow!("page_text returned malformed output: {}", out.stdout.trim()) + }) + } + + /// Open a URL in the sandbox Chrome and (optionally) poll for a + /// post-auth signal. + /// + /// Returns immediately with `status = "auth_required"` and the noVNC + /// URL if no `wait_for_*` condition is set; otherwise it polls inside + /// the container until the condition is met or `timeout_seconds` + /// elapses. + pub async fn auth_handoff( + &self, + target: &str, + opts: &AuthHandoffOptions, + ) -> Result { + let payload = serde_json::json!({ + "url": opts.url, + "wait_for_selector": opts.wait_for_selector, + "wait_for_url_contains": opts.wait_for_url_contains, + "timeout_seconds": opts.timeout_seconds, + "user_data_dir": opts.user_data_dir, + "headless": false, + }); + + let payload_str = + serde_json::to_string(&payload).context("failed to serialize auth_handoff payload")?; + + let cmd = format!( + "REACH_AUTH_HANDOFF_PAYLOAD={} python3 -c {}", + shell_single_quote(&payload_str), + shell_single_quote(AUTH_HANDOFF_SCRIPT), + ); + + let out = self + .exec(target, &["bash".into(), "-c".into(), cmd]) + .await?; + + if let Some(parsed) = parse_auth_handoff_json(&out.stdout) { + return Ok(parsed); + } + + if out.exit_code != 0 { + bail!( + "auth_handoff exec failed (exit {}): {}", + out.exit_code, + out.stderr + ); + } + + Err(anyhow::anyhow!( + "auth_handoff returned malformed output: {}", + out.stdout.trim() + )) + } + pub async fn wait_healthy(&self, target: &str, timeout: Duration) -> Result<()> { let deadline = tokio::time::Instant::now() + timeout; loop { @@ -445,3 +600,331 @@ fn extract_ports(ports: &[bollard::models::Port]) -> SandboxPortMapping { mapping } + +// ═══════════════════════════════════════════════════════════ +// page_text + auth_handoff: types, helpers, embedded Python +// ═══════════════════════════════════════════════════════════ + +/// Inputs to [`DockerClient::page_text`]. +#[derive(Debug, Clone, Default)] +pub struct PageTextOptions { + pub url: String, + pub wait_for: Option, + pub selector: Option, + pub timeout_ms: u64, + /// Persistent Chrome user data dir inside the container. + pub user_data_dir: Option, +} + +/// Parsed output from the embedded `page_text` Python helper. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct PageTextOutput { + pub status: String, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub message: Option, +} + +/// Inputs to [`DockerClient::auth_handoff`]. +#[derive(Debug, Clone, Default)] +pub struct AuthHandoffOptions { + pub url: String, + pub wait_for_selector: Option, + pub wait_for_url_contains: Option, + pub timeout_seconds: u64, + pub user_data_dir: Option, +} + +/// Parsed output from the embedded `auth_handoff` Python helper. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct AuthHandoffOutput { + pub status: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub message: Option, +} + +/// Quote a string so it survives a single-quoted bash word. +/// +/// Replaces every `'` with `'\''` and wraps the result in single quotes. +pub(crate) fn shell_single_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +/// Build a noVNC URL for a sandbox given its mapped port. +pub fn novnc_url(host: &str, port: u16) -> String { + format!("http://{host}:{port}/vnc.html?autoconnect=1&resize=remote") +} + +fn parse_page_text_json(stdout: &str) -> Option { + last_json_line(stdout).and_then(|l| serde_json::from_str(&l).ok()) +} + +fn parse_auth_handoff_json(stdout: &str) -> Option { + last_json_line(stdout).and_then(|l| serde_json::from_str(&l).ok()) +} + +/// Find the last non-empty line in `stdout` that looks like a JSON object. +/// +/// The Python helpers may print warnings on stdout (Playwright, etc.) +/// before the result line, so we scan from the bottom. +fn last_json_line(stdout: &str) -> Option { + stdout + .lines() + .rev() + .map(str::trim) + .find(|l| l.starts_with('{') && l.ends_with('}')) + .map(|l| l.to_string()) +} + +/// Embedded Playwright "navigate and extract text" helper. +/// +/// Reads its JSON payload from `REACH_PAGE_TEXT_PAYLOAD` and prints a +/// single-line JSON object on stdout. Always exits 0 so the caller can +/// distinguish "Python ran but the page failed to load" from "exec +/// failed entirely". +pub const PAGE_TEXT_SCRIPT: &str = r#" +import json +import os +import sys + +payload = json.loads(os.environ.get("REACH_PAGE_TEXT_PAYLOAD", "{}")) +url = payload.get("url") +wait_for = payload.get("wait_for") +selector = payload.get("selector") +timeout_ms = int(payload.get("timeout_ms") or 30000) +user_data_dir = payload.get("user_data_dir") + +if not url: + print(json.dumps({"status": "error", "message": "missing url"})) + sys.exit(0) + +try: + from playwright.sync_api import sync_playwright +except Exception as exc: # pragma: no cover + print(json.dumps({"status": "error", "message": f"playwright import failed: {exc}"})) + sys.exit(0) + +os.environ.setdefault("DISPLAY", ":99") + +try: + with sync_playwright() as p: + if user_data_dir: + os.makedirs(user_data_dir, exist_ok=True) + ctx = p.chromium.launch_persistent_context( + user_data_dir=user_data_dir, + headless=False, + args=["--no-sandbox", "--disable-gpu", "--no-first-run"], + ) + page = ctx.new_page() if not ctx.pages else ctx.pages[0] + owner = ctx + else: + browser = p.chromium.launch( + headless=False, + args=["--no-sandbox", "--disable-gpu", "--no-first-run"], + ) + page = browser.new_page() + owner = browser + + try: + page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded") + if wait_for: + page.wait_for_selector(wait_for, timeout=timeout_ms) + else: + try: + page.wait_for_load_state("networkidle", timeout=timeout_ms) + except Exception: + pass + + if selector: + el = page.query_selector(selector) + text = el.inner_text() if el else "" + else: + text = page.locator("body").inner_text() + + result = { + "status": "ok", + "url": page.url, + "title": page.title(), + "text": text, + } + finally: + try: + owner.close() + except Exception: + pass +except Exception as exc: + result = {"status": "error", "message": str(exc)} + +print(json.dumps(result)) +"#; + +/// Embedded Playwright auth-handoff helper. +/// +/// Launches a persistent Chromium context (so the user can log in via +/// noVNC), then either returns immediately or polls for a selector / URL +/// substring before returning. +pub const AUTH_HANDOFF_SCRIPT: &str = r#" +import json +import os +import sys +import time + +payload = json.loads(os.environ.get("REACH_AUTH_HANDOFF_PAYLOAD", "{}")) +url = payload.get("url") +wait_for_selector = payload.get("wait_for_selector") +wait_for_url_contains = payload.get("wait_for_url_contains") +timeout_seconds = int(payload.get("timeout_seconds") or 300) +user_data_dir = payload.get("user_data_dir") or "/home/sandbox/.config/google-chrome-profiles/_reach_default" + +if not url: + print(json.dumps({"status": "error", "message": "missing url"})) + sys.exit(0) + +try: + from playwright.sync_api import sync_playwright +except Exception as exc: # pragma: no cover + print(json.dumps({"status": "error", "message": f"playwright import failed: {exc}"})) + sys.exit(0) + +os.environ.setdefault("DISPLAY", ":99") +os.makedirs(user_data_dir, exist_ok=True) + +needs_wait = bool(wait_for_selector or wait_for_url_contains) + +try: + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + user_data_dir=user_data_dir, + headless=False, + args=["--no-sandbox", "--disable-gpu", "--no-first-run"], + ) + page = ctx.new_page() if not ctx.pages else ctx.pages[0] + + try: + page.goto(url, timeout=30000, wait_until="domcontentloaded") + except Exception as exc: + result = { + "status": "error", + "message": f"navigation failed: {exc}", + "url": url, + } + ctx.close() + print(json.dumps(result)) + sys.exit(0) + + if not needs_wait: + # Detach so the browser keeps running for the human. + print(json.dumps({ + "status": "auth_required", + "url": page.url, + "message": "Open the noVNC URL to log in. Re-call once done.", + })) + sys.exit(0) + + deadline = time.time() + timeout_seconds + matched = False + while time.time() < deadline: + try: + if wait_for_url_contains and wait_for_url_contains in page.url: + matched = True + break + if wait_for_selector and page.query_selector(wait_for_selector): + matched = True + break + except Exception: + pass + time.sleep(1) + + result = { + "status": "authenticated" if matched else "timeout", + "url": page.url, + "message": None if matched else "auth signal not seen before timeout", + } + ctx.close() +except Exception as exc: + result = {"status": "error", "message": str(exc)} + +print(json.dumps(result)) +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_single_quote_handles_quotes() { + assert_eq!(shell_single_quote("hello"), "'hello'"); + assert_eq!(shell_single_quote("it's"), "'it'\\''s'"); + assert_eq!(shell_single_quote("a 'b' c"), "'a '\\''b'\\'' c'"); + } + + #[test] + fn last_json_line_picks_trailing_object() { + let stdout = "warning: foo\nINFO: bar\n{\"status\":\"ok\",\"text\":\"hi\"}\n"; + assert_eq!( + last_json_line(stdout).as_deref(), + Some("{\"status\":\"ok\",\"text\":\"hi\"}") + ); + } + + #[test] + fn last_json_line_returns_none_when_absent() { + assert!(last_json_line("no json here\nstill nothing").is_none()); + } + + #[test] + fn parse_page_text_json_round_trip() { + let stdout = "noise\n{\"status\":\"ok\",\"text\":\"hello\",\"url\":\"https://x\"}\n"; + let parsed = parse_page_text_json(stdout).unwrap(); + assert_eq!(parsed.status, "ok"); + assert_eq!(parsed.text.as_deref(), Some("hello")); + assert_eq!(parsed.url.as_deref(), Some("https://x")); + } + + #[test] + fn parse_auth_handoff_json_round_trip() { + let stdout = "{\"status\":\"auth_required\",\"url\":\"https://x\"}"; + let parsed = parse_auth_handoff_json(stdout).unwrap(); + assert_eq!(parsed.status, "auth_required"); + assert_eq!(parsed.url.as_deref(), Some("https://x")); + } + + #[test] + fn novnc_url_format() { + assert_eq!( + novnc_url("localhost", 6080), + "http://localhost:6080/vnc.html?autoconnect=1&resize=remote" + ); + } + + #[test] + fn profile_mount_paths() { + assert_eq!( + ProfileMount::container_path_for("personal"), + "/home/sandbox/.config/google-chrome-profiles/personal" + ); + let base = std::path::Path::new("/tmp/reach/profiles"); + let host = ProfileMount::host_path_for(base, "personal"); + assert_eq!( + host, + std::path::PathBuf::from("/tmp/reach/profiles/personal") + ); + } +} diff --git a/crates/reach-cli/src/mcp.rs b/crates/reach-cli/src/mcp.rs index f573445..ff3ef00 100644 --- a/crates/reach-cli/src/mcp.rs +++ b/crates/reach-cli/src/mcp.rs @@ -139,6 +139,10 @@ pub enum ToolCall { PlaywrightEval(PlaywrightEvalParams), #[serde(rename = "exec")] Exec(ExecParams), + #[serde(rename = "page_text")] + PageText(PageTextParams), + #[serde(rename = "auth_handoff")] + AuthHandoff(AuthHandoffParams), } // ═══════════════════════════════════════════════════════════ @@ -250,6 +254,36 @@ pub struct ExecParams { pub sandbox: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageTextParams { + pub url: String, + #[serde(default)] + pub wait_for: Option, + #[serde(default)] + pub selector: Option, + #[serde(default = "default_page_text_timeout")] + pub timeout_ms: u64, + #[serde(default)] + pub use_profile: Option, + #[serde(default)] + pub sandbox: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthHandoffParams { + pub url: String, + #[serde(default)] + pub wait_for_selector: Option, + #[serde(default)] + pub wait_for_url_contains: Option, + #[serde(default = "default_auth_timeout_seconds")] + pub timeout_seconds: u64, + #[serde(default)] + pub use_profile: Option, + #[serde(default)] + pub sandbox: Option, +} + fn default_true() -> bool { true } @@ -258,6 +292,14 @@ fn default_timeout() -> u32 { 30 } +fn default_page_text_timeout() -> u64 { + 30_000 +} + +fn default_auth_timeout_seconds() -> u64 { + 300 +} + // ═══════════════════════════════════════════════════════════ // Tool results — what comes back from the sandbox // ═══════════════════════════════════════════════════════════ @@ -474,5 +516,112 @@ pub fn tool_definitions() -> Vec { } }), }, + ToolDefinition { + name: "page_text".into(), + description: + "Navigate to a URL using Playwright (real Chromium), wait for the page to render, \ + and return the visible text content. Handles JS-heavy SPAs that Scrapling can't." + .into(), + input_schema: serde_json::json!({ + "type": "object", + "required": ["url"], + "properties": { + "url": { "type": "string", "description": "URL to load" }, + "wait_for": { + "type": "string", + "description": "CSS selector to wait for before extracting (default: networkidle)" + }, + "selector": { + "type": "string", + "description": "Only extract text from elements matching this selector (default: body)" + }, + "timeout_ms": { + "type": "integer", + "default": 30000, + "description": "Max wait time in milliseconds" + }, + "use_profile": { + "type": "string", + "description": "Persistent Chrome profile name (see `reach create --persist-profile`)" + }, + "sandbox": { "type": "string" } + } + }), + }, + ToolDefinition { + name: "auth_handoff".into(), + description: + "Open a URL in the sandbox's Chrome and pause until the user has authenticated. \ + Returns the noVNC URL the user should open to perform login. Optionally polls \ + for a CSS selector or URL substring that indicates auth is complete." + .into(), + input_schema: serde_json::json!({ + "type": "object", + "required": ["url"], + "properties": { + "url": { "type": "string", "description": "URL that requires auth" }, + "wait_for_selector": { + "type": "string", + "description": "CSS selector that appears after successful auth" + }, + "wait_for_url_contains": { + "type": "string", + "description": "Substring that should appear in the URL after auth" + }, + "timeout_seconds": { + "type": "integer", + "default": 300, + "description": "How long to wait for the auth signal" + }, + "use_profile": { + "type": "string", + "description": "Persistent Chrome profile name (see `reach create --persist-profile`)" + }, + "sandbox": { "type": "string" } + } + }), + }, ] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_includes_new_tools() { + let names: Vec = tool_definitions().into_iter().map(|t| t.name).collect(); + assert!(names.contains(&"page_text".to_string())); + assert!(names.contains(&"auth_handoff".to_string())); + } + + #[test] + fn page_text_schema_marks_url_required() { + let tool = tool_definitions() + .into_iter() + .find(|t| t.name == "page_text") + .unwrap(); + let required = tool + .input_schema + .get("required") + .unwrap() + .as_array() + .unwrap(); + assert!(required.iter().any(|v| v == "url")); + } + + #[test] + fn auth_handoff_schema_marks_url_required() { + let tool = tool_definitions() + .into_iter() + .find(|t| t.name == "auth_handoff") + .unwrap(); + let required = tool + .input_schema + .get("required") + .unwrap() + .as_array() + .unwrap(); + assert!(required.iter().any(|v| v == "url")); + } +} diff --git a/crates/reach-cli/tests/config_types.rs b/crates/reach-cli/tests/config_types.rs index 879d8a3..a007178 100644 --- a/crates/reach-cli/tests/config_types.rs +++ b/crates/reach-cli/tests/config_types.rs @@ -102,3 +102,46 @@ fn config_path_ends_with_reach_config_toml() { let path = ReachConfig::config_path(); assert!(path.ends_with("reach/config.toml")); } + +// ═══════════════════════════════════════════════════════════ +// Profile directory +// ═══════════════════════════════════════════════════════════ + +#[test] +fn profile_dir_defaults_to_none_in_serde() { + let config = ReachConfig::default(); + assert!(config.sandbox.profile_dir.is_none()); +} + +#[test] +fn profile_dir_can_be_overridden_via_toml() { + let config: ReachConfig = toml::from_str( + r#" + [sandbox] + profile_dir = "/srv/reach/profiles" + "#, + ) + .unwrap(); + assert_eq!( + config.sandbox.profile_dir, + Some(std::path::PathBuf::from("/srv/reach/profiles")) + ); + assert_eq!( + config.sandbox.resolved_profile_dir(), + std::path::PathBuf::from("/srv/reach/profiles") + ); +} + +#[test] +fn resolved_profile_dir_falls_back_when_unset() { + let config = ReachConfig::default(); + let resolved = config.sandbox.resolved_profile_dir(); + assert!(resolved.to_string_lossy().contains("reach")); + assert!(resolved.ends_with("profiles")); +} + +#[test] +fn default_profile_dir_helper_returns_reach_path() { + let dir = default_profile_dir(); + assert!(dir.to_string_lossy().contains("reach")); +} diff --git a/crates/reach-cli/tests/docker_types.rs b/crates/reach-cli/tests/docker_types.rs index 872e53d..5f12c74 100644 --- a/crates/reach-cli/tests/docker_types.rs +++ b/crates/reach-cli/tests/docker_types.rs @@ -149,3 +149,54 @@ fn exec_output_serializes() { assert_eq!(json["exit_code"], 0); assert_eq!(json["stdout"], "hello\n"); } + +// ═══════════════════════════════════════════════════════════ +// Profile mounts +// ═══════════════════════════════════════════════════════════ + +#[test] +fn profile_mount_container_path_uses_stable_root() { + assert_eq!( + ProfileMount::container_path_for("threads"), + "/home/sandbox/.config/google-chrome-profiles/threads" + ); +} + +#[test] +fn profile_mount_host_path_joins_under_base() { + let base = std::path::Path::new("/var/lib/reach/profiles"); + let host = ProfileMount::host_path_for(base, "threads"); + assert_eq!( + host, + std::path::PathBuf::from("/var/lib/reach/profiles/threads") + ); +} + +#[test] +fn labels_for_sandbox_includes_profile_label_when_set() { + let config = SandboxConfig { + profile: Some(ProfileMount { + name: "threads".into(), + host_path: std::path::PathBuf::from("/tmp/x"), + container_path: ProfileMount::container_path_for("threads"), + }), + ..SandboxConfig::default() + }; + let labels = Labels::for_sandbox(&config); + assert_eq!(labels.get(Labels::PROFILE), Some(&"threads".to_string())); +} + +#[test] +fn labels_for_sandbox_omits_profile_label_when_unset() { + let config = SandboxConfig::default(); + let labels = Labels::for_sandbox(&config); + assert!(!labels.contains_key(Labels::PROFILE)); +} + +#[test] +fn novnc_url_uses_localhost_pattern() { + assert_eq!( + novnc_url("localhost", 6080), + "http://localhost:6080/vnc.html?autoconnect=1&resize=remote" + ); +} diff --git a/crates/reach-cli/tests/e2e_container.rs b/crates/reach-cli/tests/e2e_container.rs index 3124a4a..d622963 100644 --- a/crates/reach-cli/tests/e2e_container.rs +++ b/crates/reach-cli/tests/e2e_container.rs @@ -524,6 +524,153 @@ fn t35_workflow_headed_and_headless_coexist() { assert!(!sh_ok("pgrep -f 'chrome.*no-sandbox' | head -1").is_empty()); } +// ═══════════════════════════════════════════════════════════ +// 12. PAGE_TEXT + AUTH_HANDOFF (Playwright SPA tooling) +// ═══════════════════════════════════════════════════════════ + +/// Run an embedded Python helper from `reach_cli::docker` inside the +/// container, passing its JSON payload via the named env var. +fn run_embedded_python(env_var: &str, payload: &serde_json::Value, script: &str) -> String { + use std::io::Write; + let payload_str = serde_json::to_string(payload).unwrap(); + + // Stage the script as a temp file inside the container so we don't + // have to escape multi-line Python on the command line. + let mut tmp = std::env::temp_dir(); + tmp.push(format!("reach_e2e_{env_var}.py")); + { + let mut f = std::fs::File::create(&tmp).unwrap(); + f.write_all(script.as_bytes()).unwrap(); + } + let host_path = tmp.to_string_lossy().to_string(); + let container_path = format!("/tmp/{}", tmp.file_name().unwrap().to_string_lossy()); + + let cp = docker(&["cp", &host_path, &format!("{CONTAINER}:{container_path}")]); + assert!(cp.status.success(), "docker cp failed: {}", stderr(&cp)); + + let cmd = format!( + "{env_var}={} python3 {container_path}", + shell_quote(&payload_str) + ); + let out = sh(&cmd); + let _ = std::fs::remove_file(&tmp); + if !out.status.success() { + panic!( + "embedded python failed (exit {}): {}\nstdout: {}", + out.status.code().unwrap_or(-1), + stderr(&out), + String::from_utf8_lossy(&out.stdout) + ); + } + String::from_utf8_lossy(&out.stdout).to_string() +} + +fn shell_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +fn last_json(stdout: &str) -> serde_json::Value { + let line = stdout + .lines() + .rev() + .map(str::trim) + .find(|l| l.starts_with('{') && l.ends_with('}')) + .unwrap_or_else(|| panic!("no JSON object in stdout: {stdout}")); + serde_json::from_str(line).unwrap_or_else(|e| panic!("bad json {line}: {e}")) +} + +#[test] +#[ignore] +fn t37_page_text_basic() { + ensure_container(); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + let payload = serde_json::json!({ + "url": "https://example.com", + "timeout_ms": 30000, + }); + let stdout = run_embedded_python( + "REACH_PAGE_TEXT_PAYLOAD", + &payload, + reach_cli::docker::PAGE_TEXT_SCRIPT, + ); + let parsed = last_json(&stdout); + assert_eq!(parsed["status"], "ok", "page_text not ok: {stdout}"); + let text = parsed["text"].as_str().unwrap_or_default(); + assert!( + text.contains("Example Domain"), + "expected text to contain 'Example Domain', got: {text}" + ); +} + +#[test] +#[ignore] +fn t38_page_text_selector() { + ensure_container(); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + let payload = serde_json::json!({ + "url": "https://example.com", + "selector": "h1", + "timeout_ms": 30000, + }); + let stdout = run_embedded_python( + "REACH_PAGE_TEXT_PAYLOAD", + &payload, + reach_cli::docker::PAGE_TEXT_SCRIPT, + ); + let parsed = last_json(&stdout); + assert_eq!( + parsed["status"], "ok", + "page_text selector not ok: {stdout}" + ); + let text = parsed["text"].as_str().unwrap_or_default(); + assert_eq!(text.trim(), "Example Domain"); +} + +#[test] +#[ignore] +fn t39_auth_handoff_returns_vnc_url() { + ensure_container(); + let _ = sh("pkill -f chrome"); + sleep_ms(500); + + // Build the noVNC URL the way `reach serve` does so we can assert on it. + let vnc = reach_cli::docker::novnc_url("localhost", 16080); + assert!(vnc.contains("vnc.html")); + assert!(vnc.contains("autoconnect=1")); + + // Drive the auth_handoff Python helper directly: no wait conditions → + // it should return status=auth_required immediately and leave Chrome + // running on the Xvfb display. + let payload = serde_json::json!({ + "url": "https://example.com", + "user_data_dir": "/home/sandbox/.config/google-chrome-profiles/_e2e", + }); + let stdout = run_embedded_python( + "REACH_AUTH_HANDOFF_PAYLOAD", + &payload, + reach_cli::docker::AUTH_HANDOFF_SCRIPT, + ); + let parsed = last_json(&stdout); + assert_eq!( + parsed["status"], "auth_required", + "auth_handoff did not return auth_required: {stdout}" + ); + let url = parsed["url"].as_str().unwrap_or_default(); + assert!(url.contains("example.com"), "unexpected url: {url}"); +} + // ═══════════════════════════════════════════════════════════ // 99. SHUTDOWN (must run last) // ═══════════════════════════════════════════════════════════ diff --git a/crates/reach-cli/tests/mcp_types.rs b/crates/reach-cli/tests/mcp_types.rs index ba1a626..2fd3c3e 100644 --- a/crates/reach-cli/tests/mcp_types.rs +++ b/crates/reach-cli/tests/mcp_types.rs @@ -61,9 +61,9 @@ fn mcp_initialize_default_has_correct_protocol_version() { // ═══════════════════════════════════════════════════════════ #[test] -fn all_eight_tools_are_registered() { +fn all_tools_are_registered() { let tools = tool_definitions(); - assert_eq!(tools.len(), 8); + assert_eq!(tools.len(), 10); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); assert!(names.contains(&"screenshot")); @@ -74,6 +74,39 @@ fn all_eight_tools_are_registered() { assert!(names.contains(&"scrape")); assert!(names.contains(&"playwright_eval")); assert!(names.contains(&"exec")); + assert!(names.contains(&"page_text")); + assert!(names.contains(&"auth_handoff")); +} + +#[test] +fn page_text_tool_requires_url() { + let tools = tool_definitions(); + let pt = tools.iter().find(|t| t.name == "page_text").unwrap(); + let required = pt.input_schema.get("required").unwrap(); + let required: Vec = serde_json::from_value(required.clone()).unwrap(); + assert!(required.contains(&"url".into())); +} + +#[test] +fn auth_handoff_tool_requires_url() { + let tools = tool_definitions(); + let ah = tools.iter().find(|t| t.name == "auth_handoff").unwrap(); + let required = ah.input_schema.get("required").unwrap(); + let required: Vec = serde_json::from_value(required.clone()).unwrap(); + assert!(required.contains(&"url".into())); +} + +#[test] +fn page_text_params_default_timeout_is_30s() { + let params: PageTextParams = serde_json::from_str(r#"{"url": "https://example.com"}"#).unwrap(); + assert_eq!(params.timeout_ms, 30_000); +} + +#[test] +fn auth_handoff_params_default_timeout_is_300s() { + let params: AuthHandoffParams = + serde_json::from_str(r#"{"url": "https://example.com"}"#).unwrap(); + assert_eq!(params.timeout_seconds, 300); } #[test] diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index dd106ea..c2e149b 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -277,6 +277,144 @@ Execute a Playwright Python script inside the sandbox. --- +## page_text + +Navigate to a URL using Playwright (real Chromium on the sandbox display) and return the visible text content. This is the right tool for JavaScript-heavy single-page apps that Scrapling can't render. + +The browser is launched headed on Xvfb so you can watch the page through noVNC if you need to debug. Pass `use_profile` to reuse a persistent Chrome profile created with `reach create --persist-profile ` and skip re-authenticating every session. + +**Parameters:** + +```json +{ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "URL to load" + }, + "wait_for": { + "type": "string", + "description": "CSS selector to wait for before extracting (default: networkidle)" + }, + "selector": { + "type": "string", + "description": "Only extract text from elements matching this selector (default: body)" + }, + "timeout_ms": { + "type": "integer", + "default": 30000, + "description": "Max wait time in milliseconds" + }, + "use_profile": { + "type": "string", + "description": "Persistent Chrome profile name (see `reach create --persist-profile`)" + }, + "sandbox": { + "type": "string" + } + } +} +``` + +**Example call:** + +```json +{ + "name": "page_text", + "arguments": { + "url": "https://www.threads.com/@todie.ai/post/DWzHGm0FRJw", + "wait_for": "article", + "use_profile": "threads" + } +} +``` + +**Returns:** JSON object with `status`, `text`, `url`, and `title` fields. + +```json +{ + "status": "ok", + "url": "https://www.threads.com/...", + "title": "Threads", + "text": "..." +} +``` + +On failure the helper still returns JSON: `{"status": "error", "message": "..."}`. + +--- + +## auth_handoff + +Open a URL in the sandbox's Chrome and pause until the user has authenticated. Returns the noVNC URL the user should open in their host browser to perform the login interactively. If `wait_for_selector` or `wait_for_url_contains` is supplied, the tool polls Playwright until the condition is met (or `timeout_seconds` elapses) and returns `status: "authenticated"`. + +The browser is launched as a `launch_persistent_context` so cookies and tokens persist for follow-up `page_text` calls — combine with `--persist-profile` on the host side to survive sandbox restarts. + +**Parameters:** + +```json +{ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "URL that requires auth" + }, + "wait_for_selector": { + "type": "string", + "description": "CSS selector that appears after successful auth" + }, + "wait_for_url_contains": { + "type": "string", + "description": "Substring that should appear in the URL after auth" + }, + "timeout_seconds": { + "type": "integer", + "default": 300, + "description": "How long to wait for the auth signal" + }, + "use_profile": { + "type": "string", + "description": "Persistent Chrome profile name (see `reach create --persist-profile`)" + }, + "sandbox": { + "type": "string" + } + } +} +``` + +**Example call:** + +```json +{ + "name": "auth_handoff", + "arguments": { + "url": "https://www.threads.com/login", + "wait_for_url_contains": "/home", + "use_profile": "threads" + } +} +``` + +**Returns:** JSON object with the noVNC URL the user should open. + +```json +{ + "status": "auth_required", + "vnc_url": "http://localhost:6080/vnc.html?autoconnect=1&resize=remote", + "url": "https://www.threads.com/login", + "instructions": "Open the vnc_url in your browser to log in. Re-call `auth_handoff` (with wait_for_*) or `page_text` once done." +} +``` + +When polling completes successfully the response is `{"status": "authenticated", ...}`. On timeout it is `{"status": "timeout", ...}`. + +--- + ## exec Run a shell command inside the sandbox. diff --git a/docs/scraping.md b/docs/scraping.md index 3e426af..757b0bd 100644 --- a/docs/scraping.md +++ b/docs/scraping.md @@ -183,13 +183,96 @@ Chrome renders on Xvfb `:99`. You can: 2. Capture it with `reach screenshot` 3. Interact with it using `click`, `type`, and `key` MCP tools +## Auth Handoff for Login-Walled Sites + +Some sites — Threads, Instagram, LinkedIn, paywalled news, internal tools — gate their content behind a login wall. Reach exposes two MCP tools that turn this into a clean human-in-the-loop flow: + +- `auth_handoff` — opens the URL in Chrome on Xvfb, returns the noVNC URL for the human, and (optionally) polls for an auth-complete signal. +- `page_text` — Playwright-driven "navigate and extract rendered text". Reuses the same persistent profile so the cookies set during `auth_handoff` carry over. + +Combine both with `reach create --persist-profile ` so the session survives sandbox destroys. + +### Threads-style example + +1. **Create a sandbox with a persistent profile:** + + ```bash + reach create --name threads --persist-profile threads + ``` + + This bind-mounts `~/.local/share/reach/profiles/threads` into the container as the Chrome user data dir. + +2. **Hand off to a human for login:** + + ```json + { + "name": "auth_handoff", + "arguments": { + "url": "https://www.threads.com/login", + "use_profile": "threads", + "wait_for_url_contains": "/home", + "timeout_seconds": 600 + } + } + ``` + + The agent gets back: + + ```json + { + "status": "auth_required", + "vnc_url": "http://localhost:6080/vnc.html?autoconnect=1&resize=remote", + "instructions": "Open the vnc_url in your browser to log in..." + } + ``` + + The human opens that URL, logs into Threads, and the polling helper notices the redirect to `/home` and returns `status: "authenticated"`. + +3. **Scrape gated content with the now-authenticated profile:** + + ```json + { + "name": "page_text", + "arguments": { + "url": "https://www.threads.com/@todie.ai/post/DWzHGm0FRJw", + "use_profile": "threads", + "wait_for": "article", + "timeout_ms": 30000 + } + } + ``` + + The Playwright helper launches a `launch_persistent_context` against the same profile, navigates, waits for the `article` element, and returns: + + ```json + { + "status": "ok", + "url": "https://www.threads.com/@todie.ai/post/DWzHGm0FRJw", + "title": "...", + "text": "..." + } + ``` + +4. **Subsequent runs skip the handoff.** As long as the cookies in `~/.local/share/reach/profiles/threads` are still valid, you can `reach destroy threads && reach create --name threads --persist-profile threads` and call `page_text` directly. + +### When polling, when not + +| Situation | wait_for_* | Pattern | +|-----------|-----------|---------| +| Quick interactive login (oauth, password) | `wait_for_url_contains` | One round trip | +| 2FA / SMS / Captcha | `wait_for_selector` on the post-auth element | Set `timeout_seconds` generously | +| Already authenticated (warm profile) | omit | Returns `auth_required` immediately, agent calls `page_text` next | + +`auth_handoff` always launches Chrome via `launch_persistent_context`, so even the "no wait" path leaves a usable session in the profile dir. + ## Choosing a Scraping Strategy | Scenario | Tool | Why | |----------|------|-----| | Static HTML, simple extraction | Scrapling | Fast, no browser overhead | | Bot-protected sites | Scrapling (StealthyFetcher) | Built-in anti-bot bypass | -| JavaScript-rendered content | Playwright | Full browser execution | +| JavaScript-rendered content (no auth) | `page_text` | Playwright with sane defaults | +| JavaScript-rendered content (login wall) | `auth_handoff` + `page_text` + `--persist-profile` | Human logs in once, agent scrapes after | | Visual interaction needed | Headed Chrome + screenshot | See what the agent sees | | Complex multi-step automation | Playwright script | Full browser API | | Adaptive selectors (resilient to changes) | Scrapling | Auto-match survives layout changes |