diff --git a/README.md b/README.md index d6a8d92..593c70c 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,41 @@ with browser: See `examples/residential_proxy_agent.py` for complete examples. +### Authentication Session Injection + +Inject pre-recorded authentication sessions (cookies + localStorage) to start your agent already logged in, bypassing login screens, 2FA, and CAPTCHAs. This saves tokens and reduces costs by eliminating login steps. + +```python +# Workflow 1: Inject pre-recorded session from file +from sentience import SentienceBrowser, save_storage_state + +# Save session after manual login +browser = SentienceBrowser() +browser.start() +browser.goto("https://example.com") +# ... log in manually ... +save_storage_state(browser.context, "auth.json") + +# Use saved session in future runs +browser = SentienceBrowser(storage_state="auth.json") +browser.start() +# Agent starts already logged in! + +# Workflow 2: Persistent sessions (cookies persist across runs) +browser = SentienceBrowser(user_data_dir="./chrome_profile") +browser.start() +# First run: Log in +# Second run: Already logged in (cookies persist automatically) +``` + +**Benefits:** +- Bypass login screens and CAPTCHAs with valid sessions +- Save 5-10 agent steps and hundreds of tokens per run +- Maintain stateful sessions for accessing authenticated pages +- Act as authenticated users (e.g., "Go to my Orders page") + +See `examples/auth_injection_agent.py` for complete examples. + ## Best Practices ### 1. Wait for Dynamic Content diff --git a/examples/auth_injection_agent.py b/examples/auth_injection_agent.py new file mode 100644 index 0000000..95ff702 --- /dev/null +++ b/examples/auth_injection_agent.py @@ -0,0 +1,263 @@ +""" +Example: Using Authentication Session Injection with SentienceAgent + +Demonstrates how to inject pre-recorded authentication sessions (cookies + localStorage) +into SentienceBrowser to start agents already logged in, bypassing login screens and CAPTCHAs. + +Two Workflows: +1. Inject Pre-recorded Session: Load a saved session from a JSON file +2. Persistent Sessions: Use a user data directory to persist sessions across runs + +Benefits: +- Bypass login screens and CAPTCHAs +- Save tokens and reduce costs (no login steps needed) +- Maintain stateful sessions across agent runs +- Act as authenticated users (access "My Orders", "My Account", etc.) + +Usage: + # Workflow 1: Inject pre-recorded session + python examples/auth_injection_agent.py --storage-state auth.json + + # Workflow 2: Use persistent user data directory + python examples/auth_injection_agent.py --user-data-dir ./chrome_profile + +Requirements: +- OpenAI API key (OPENAI_API_KEY) for LLM +- Optional: Sentience API key (SENTIENCE_API_KEY) for Pro/Enterprise features +- Optional: Pre-saved storage state file (auth.json) or user data directory +""" + +import argparse +import os + +from sentience import SentienceAgent, SentienceBrowser, save_storage_state +from sentience.llm_provider import OpenAIProvider + + +def example_inject_storage_state(): + """Example 1: Inject pre-recorded session from file""" + print("=" * 60) + print("Example 1: Inject Pre-recorded Session") + print("=" * 60) + + # Path to saved storage state file + # You can create this file using save_storage_state() after logging in manually + storage_state_file = "auth.json" + + if not os.path.exists(storage_state_file): + print(f"\n⚠️ Storage state file not found: {storage_state_file}") + print("\n To create this file:") + print(" 1. Log in manually to your target website") + print(" 2. Use save_storage_state() to save the session") + print("\n Example code:") + print(" ```python") + print(" from sentience import SentienceBrowser, save_storage_state") + print(" browser = SentienceBrowser()") + print(" browser.start()") + print(" browser.goto('https://example.com')") + print(" # ... log in manually ...") + print(" save_storage_state(browser.context, 'auth.json')") + print(" ```") + print("\n Skipping this example...\n") + return + + openai_key = os.environ.get("OPENAI_API_KEY") + if not openai_key: + print("❌ Error: OPENAI_API_KEY not set") + return + + # Create browser with storage state injection + browser = SentienceBrowser( + storage_state=storage_state_file, # Inject saved session + headless=False, + ) + + llm = OpenAIProvider(api_key=openai_key, model="gpt-4o-mini") + agent = SentienceAgent(browser, llm, verbose=True) + + try: + print("\nπŸš€ Starting browser with injected session...") + browser.start() + + print("🌐 Navigating to authenticated page...") + # Agent starts already logged in! + browser.page.goto("https://example.com/orders") # Or your authenticated page + browser.page.wait_for_load_state("networkidle") + + print("\nβœ… Browser started with pre-injected authentication!") + print(" Agent can now access authenticated pages without logging in") + + # Example: Use agent on authenticated pages + agent.act("Show me my recent orders") + agent.act("Click on the first order") + + print("\nβœ… Agent execution complete!") + + except Exception as e: + print(f"\n❌ Error: {e}") + raise + + finally: + browser.close() + + +def example_persistent_session(): + """Example 2: Use persistent user data directory""" + print("=" * 60) + print("Example 2: Persistent Session (User Data Directory)") + print("=" * 60) + + # Directory to persist browser session + user_data_dir = "./chrome_profile" + + openai_key = os.environ.get("OPENAI_API_KEY") + if not openai_key: + print("❌ Error: OPENAI_API_KEY not set") + return + + # Create browser with persistent user data directory + browser = SentienceBrowser( + user_data_dir=user_data_dir, # Persist cookies and localStorage + headless=False, + ) + + llm = OpenAIProvider(api_key=openai_key, model="gpt-4o-mini") + agent = SentienceAgent(browser, llm, verbose=True) + + try: + print("\nπŸš€ Starting browser with persistent session...") + browser.start() + + # Check if this is first run (no existing session) + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # First run: Agent needs to log in + # Second run: Agent is already logged in (cookies persist) + if os.path.exists(user_data_dir): + print("\nβœ… Using existing session from previous run") + print(" Cookies and localStorage are loaded automatically") + else: + print("\nπŸ“ First run - session will be saved after login") + print(" Next run will automatically use saved session") + + # Example: Log in (first run) or use existing session (subsequent runs) + agent.act("Click the sign in button") + agent.act("Type your email into the email field") + agent.act("Type your password into the password field") + agent.act("Click the login button") + + print("\nβœ… Session will persist in:", user_data_dir) + print(" Next run will automatically use this session") + + except Exception as e: + print(f"\n❌ Error: {e}") + raise + + finally: + browser.close() + + +def example_save_storage_state(): + """Example 3: Save current session for later use""" + print("=" * 60) + print("Example 3: Save Current Session") + print("=" * 60) + + openai_key = os.environ.get("OPENAI_API_KEY") + if not openai_key: + print("❌ Error: OPENAI_API_KEY not set") + return + + browser = SentienceBrowser(headless=False) + llm = OpenAIProvider(api_key=openai_key, model="gpt-4o-mini") + agent = SentienceAgent(browser, llm, verbose=True) + + try: + print("\nπŸš€ Starting browser...") + browser.start() + + print("🌐 Navigate to your target website and log in manually...") + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + print("\n⏸️ Please log in manually in the browser window") + print(" Press Enter when you're done logging in...") + input() + + # Save the current session + storage_state_file = "auth.json" + save_storage_state(browser.context, storage_state_file) + + print(f"\nβœ… Session saved to: {storage_state_file}") + print(" You can now use this file with storage_state parameter:") + print(f" browser = SentienceBrowser(storage_state='{storage_state_file}')") + + except Exception as e: + print(f"\n❌ Error: {e}") + raise + + finally: + browser.close() + + +def main(): + """Run auth injection examples""" + parser = argparse.ArgumentParser(description="Auth Injection Examples") + parser.add_argument( + "--storage-state", + type=str, + help="Path to storage state JSON file to inject", + ) + parser.add_argument( + "--user-data-dir", + type=str, + help="Path to user data directory for persistent sessions", + ) + parser.add_argument( + "--save-session", + action="store_true", + help="Save current session to auth.json", + ) + + args = parser.parse_args() + + print("\n" + "=" * 60) + print("Sentience SDK - Authentication Session Injection Examples") + print("=" * 60 + "\n") + + if args.save_session: + example_save_storage_state() + elif args.storage_state: + # Override default file path + import sys + + sys.modules[__name__].storage_state_file = args.storage_state + example_inject_storage_state() + elif args.user_data_dir: + # Override default directory + import sys + + sys.modules[__name__].user_data_dir = args.user_data_dir + example_persistent_session() + else: + # Run all examples + example_save_storage_state() + print("\n") + example_inject_storage_state() + print("\n") + example_persistent_session() + + print("\n" + "=" * 60) + print("Examples Complete!") + print("=" * 60) + print("\nπŸ’‘ Tips:") + print(" - Use storage_state to inject pre-recorded sessions") + print(" - Use user_data_dir to persist sessions across runs") + print(" - Save sessions after manual login for reuse") + print(" - Bypass login screens and CAPTCHAs with valid sessions") + print(" - Reduce token costs by skipping login steps\n") + + +if __name__ == "__main__": + main() diff --git a/screenshot.png b/screenshot.png index b6b7e62..e96bc42 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/sentience/__init__.py b/sentience/__init__.py index 6b7a2f2..c3927e4 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -32,11 +32,15 @@ ActionTokenUsage, AgentActionResult, BBox, + Cookie, Element, + LocalStorageItem, + OriginStorage, ScreenshotConfig, Snapshot, SnapshotFilter, SnapshotOptions, + StorageState, TokenStats, Viewport, WaitResult, @@ -54,6 +58,7 @@ canonical_snapshot_loose, canonical_snapshot_strict, compute_snapshot_digests, + save_storage_state, sha256_digest, ) from .wait import wait_for @@ -105,6 +110,11 @@ "SnapshotOptions", "SnapshotFilter", "ScreenshotConfig", + # Storage State Models (Auth Injection) + "StorageState", + "Cookie", + "LocalStorageItem", + "OriginStorage", # Tracing (v0.12.0+) "Tracer", "TraceSink", @@ -118,6 +128,7 @@ "canonical_snapshot_loose", "compute_snapshot_digests", "sha256_digest", + "save_storage_state", # Formatting (v0.12.0+) "format_snapshot_for_llm", # Agent Config (v0.12.0+) diff --git a/sentience/browser.py b/sentience/browser.py index e465d4d..efc41a0 100644 --- a/sentience/browser.py +++ b/sentience/browser.py @@ -11,7 +11,7 @@ from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright -from sentience.models import ProxyConfig +from sentience.models import ProxyConfig, StorageState # Import stealth for bot evasion (optional - graceful fallback if not available) try: @@ -31,6 +31,8 @@ def __init__( api_url: str | None = None, headless: bool | None = None, proxy: str | None = None, + user_data_dir: str | None = None, + storage_state: str | Path | StorageState | dict | None = None, ): """ Initialize Sentience browser @@ -46,6 +48,15 @@ def __init__( proxy: Optional proxy server URL (e.g., 'http://user:pass@proxy.example.com:8080') Supports HTTP, HTTPS, and SOCKS5 proxies Falls back to SENTIENCE_PROXY environment variable if not provided + user_data_dir: Optional path to user data directory for persistent sessions. + If None, uses temporary directory (session not persisted). + If provided, cookies and localStorage persist across browser restarts. + storage_state: Optional storage state to inject (cookies + localStorage). + Can be: + - Path to JSON file (str or Path) + - StorageState object + - Dictionary with 'cookies' and/or 'origins' keys + If provided, browser starts with pre-injected authentication. """ self.api_key = api_key # Only set api_url if api_key is provided, otherwise None (free tier) @@ -65,6 +76,10 @@ def __init__( # Support proxy from argument or environment variable self.proxy = proxy or os.environ.get("SENTIENCE_PROXY") + # Auth injection support + self.user_data_dir = user_data_dir + self.storage_state = storage_state + self.playwright: Playwright | None = None self.context: BrowserContext | None = None self.page: Page | None = None @@ -170,9 +185,16 @@ def start(self) -> None: # Parse proxy configuration if provided proxy_config = self._parse_proxy(self.proxy) if self.proxy else None + # Handle User Data Directory (Persistence) + if self.user_data_dir: + user_data_dir = str(self.user_data_dir) + Path(user_data_dir).mkdir(parents=True, exist_ok=True) + else: + user_data_dir = "" # Ephemeral temp dir (existing behavior) + # Build launch_persistent_context parameters launch_params = { - "user_data_dir": "", # Ephemeral temp dir + "user_data_dir": user_data_dir, "headless": False, # IMPORTANT: See note above "args": args, "viewport": {"width": 1280, "height": 800}, @@ -194,6 +216,10 @@ def start(self) -> None: self.page = self.context.pages[0] if self.context.pages else self.context.new_page() + # Inject storage state if provided (must be after context creation) + if self.storage_state: + self._inject_storage_state(self.storage_state) + # Apply stealth if available if STEALTH_AVAILABLE: stealth_sync(self.page) @@ -233,6 +259,92 @@ def goto(self, url: str) -> None: f"5. Diagnostic info: {diag}" ) + def _inject_storage_state( + self, storage_state: str | Path | StorageState | dict + ) -> None: # noqa: C901 + """ + Inject storage state (cookies + localStorage) into browser context. + + Args: + storage_state: Path to JSON file, StorageState object, or dict containing storage state + """ + import json + + # Load storage state + if isinstance(storage_state, (str, Path)): + # Load from file + with open(storage_state, encoding="utf-8") as f: + state_dict = json.load(f) + state = StorageState.from_dict(state_dict) + elif isinstance(storage_state, StorageState): + # Already a StorageState object + state = storage_state + elif isinstance(storage_state, dict): + # Dictionary format + state = StorageState.from_dict(storage_state) + else: + raise ValueError( + f"Invalid storage_state type: {type(storage_state)}. " + "Expected str, Path, StorageState, or dict." + ) + + # Inject cookies (works globally) + if state.cookies: + # Convert to Playwright cookie format + playwright_cookies = [] + for cookie in state.cookies: + cookie_dict = cookie.model_dump() + # Playwright expects lowercase keys for some fields + playwright_cookie = { + "name": cookie_dict["name"], + "value": cookie_dict["value"], + "domain": cookie_dict["domain"], + "path": cookie_dict["path"], + } + if cookie_dict.get("expires"): + playwright_cookie["expires"] = cookie_dict["expires"] + if cookie_dict.get("httpOnly"): + playwright_cookie["httpOnly"] = cookie_dict["httpOnly"] + if cookie_dict.get("secure"): + playwright_cookie["secure"] = cookie_dict["secure"] + if cookie_dict.get("sameSite"): + playwright_cookie["sameSite"] = cookie_dict["sameSite"] + playwright_cookies.append(playwright_cookie) + + self.context.add_cookies(playwright_cookies) + print(f"βœ… [Sentience] Injected {len(state.cookies)} cookie(s)") + + # Inject LocalStorage (requires navigation to each domain) + if state.origins: + for origin_data in state.origins: + origin = origin_data.origin + if not origin: + continue + + # Navigate to origin to set localStorage + try: + self.page.goto(origin, wait_until="domcontentloaded", timeout=10000) + + # Inject localStorage + if origin_data.localStorage: + # Convert to dict format for JavaScript + localStorage_dict = { + item.name: item.value for item in origin_data.localStorage + } + self.page.evaluate( + """(localStorage_data) => { + for (const [key, value] of Object.entries(localStorage_data)) { + localStorage.setItem(key, value); + } + }""", + localStorage_dict, + ) + print( + f"βœ… [Sentience] Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}" + ) + except Exception as e: + print(f"⚠️ [Sentience] Failed to inject localStorage for {origin}: {e}") + def _wait_for_extension(self, timeout_sec: float = 5.0) -> bool: """Poll for window.sentience to be available""" start_time = time.time() diff --git a/sentience/models.py b/sentience/models.py index 6d617c1..b0ec47b 100644 --- a/sentience/models.py +++ b/sentience/models.py @@ -216,3 +216,124 @@ def to_playwright_dict(self) -> dict: config["username"] = self.username config["password"] = self.password return config + + +# ========== Storage State Models (Auth Injection) ========== + + +class Cookie(BaseModel): + """ + Cookie definition for storage state injection. + + Matches Playwright's cookie format for storage_state. + """ + + name: str = Field(..., description="Cookie name") + value: str = Field(..., description="Cookie value") + domain: str = Field(..., description="Cookie domain (e.g., '.example.com')") + path: str = Field(default="/", description="Cookie path") + expires: float | None = Field(None, description="Expiration timestamp (Unix epoch)") + httpOnly: bool = Field(default=False, description="HTTP-only flag") + secure: bool = Field(default=False, description="Secure (HTTPS-only) flag") + sameSite: Literal["Strict", "Lax", "None"] = Field( + default="Lax", description="SameSite attribute" + ) + + +class LocalStorageItem(BaseModel): + """ + LocalStorage item for a specific origin. + + Playwright stores localStorage as an array of {name, value} objects. + """ + + name: str = Field(..., description="LocalStorage key") + value: str = Field(..., description="LocalStorage value") + + +class OriginStorage(BaseModel): + """ + Storage state for a specific origin (localStorage). + + Represents localStorage data for a single domain. + """ + + origin: str = Field(..., description="Origin URL (e.g., 'https://example.com')") + localStorage: list[LocalStorageItem] = Field( + default_factory=list, description="LocalStorage items for this origin" + ) + + +class StorageState(BaseModel): + """ + Complete browser storage state (cookies + localStorage). + + This is the format used by Playwright's storage_state() method. + Can be saved to/loaded from JSON files for session injection. + """ + + cookies: list[Cookie] = Field( + default_factory=list, description="Cookies to inject (global scope)" + ) + origins: list[OriginStorage] = Field( + default_factory=list, description="LocalStorage data per origin" + ) + + @classmethod + def from_dict(cls, data: dict) -> "StorageState": + """ + Create StorageState from dictionary (e.g., loaded from JSON). + + Args: + data: Dictionary with 'cookies' and/or 'origins' keys + + Returns: + StorageState instance + """ + cookies = [ + Cookie(**cookie) if isinstance(cookie, dict) else cookie + for cookie in data.get("cookies", []) + ] + origins = [] + for origin_data in data.get("origins", []): + if isinstance(origin_data, dict): + # Handle localStorage as array of {name, value} or as dict + localStorage_data = origin_data.get("localStorage", []) + if isinstance(localStorage_data, dict): + # Convert dict to list of LocalStorageItem + localStorage_items = [ + LocalStorageItem(name=k, value=v) for k, v in localStorage_data.items() + ] + else: + # Already a list + localStorage_items = [ + LocalStorageItem(**item) if isinstance(item, dict) else item + for item in localStorage_data + ] + origins.append( + OriginStorage( + origin=origin_data.get("origin", ""), + localStorage=localStorage_items, + ) + ) + else: + origins.append(origin_data) + return cls(cookies=cookies, origins=origins) + + def to_playwright_dict(self) -> dict: + """ + Convert to Playwright-compatible dictionary format. + + Returns: + Dictionary compatible with Playwright's storage_state parameter + """ + return { + "cookies": [cookie.model_dump() for cookie in self.cookies], + "origins": [ + { + "origin": origin.origin, + "localStorage": [item.model_dump() for item in origin.localStorage], + } + for origin in self.origins + ], + } diff --git a/sentience/utils.py b/sentience/utils.py index 64a942b..286d0af 100644 --- a/sentience/utils.py +++ b/sentience/utils.py @@ -11,7 +11,10 @@ import json import re from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Union +from pathlib import Path +from typing import Any + +from playwright.sync_api import BrowserContext @dataclass @@ -255,3 +258,39 @@ def compute_snapshot_digests(elements: list[dict[str, Any]]) -> dict[str, str]: "strict": sha256_digest(canonical_strict), "loose": sha256_digest(canonical_loose), } + + +def save_storage_state(context: BrowserContext, file_path: str | Path) -> None: + """ + Save current browser storage state (cookies + localStorage) to a file. + + This is useful for capturing a logged-in session to reuse later. + + Args: + context: Playwright BrowserContext + file_path: Path to save the storage state JSON file + + Example: + ```python + from sentience import SentienceBrowser, save_storage_state + + browser = SentienceBrowser() + browser.start() + + # User logs in manually or via agent + browser.goto("https://example.com") + # ... login happens ... + + # Save session for later + save_storage_state(browser.context, "auth.json") + ``` + + Raises: + IOError: If file cannot be written + """ + storage_state = context.storage_state() + file_path_obj = Path(file_path) + file_path_obj.parent.mkdir(parents=True, exist_ok=True) + with open(file_path_obj, "w") as f: + json.dump(storage_state, f, indent=2) + print(f"βœ… [Sentience] Saved storage state to {file_path_obj}")