diff --git a/README.md b/README.md index 3c029bf7..f66f5f88 100644 --- a/README.md +++ b/README.md @@ -778,6 +778,55 @@ ts-node examples/proxy-example.ts --proxy=http://user:pass@proxy.com:8000 **Note:** The proxy is configured at the browser level, so all traffic (including the Chrome extension) routes through the proxy. No changes to the extension are required. +### 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. + +```typescript +// Workflow 1: Inject pre-recorded session from file +import { SentienceBrowser, saveStorageState } from 'sentience-ts'; + +// Save session after manual login +const browser = new SentienceBrowser(); +await browser.start(); +await browser.getPage().goto('https://example.com'); +// ... log in manually ... +await saveStorageState(browser.getContext(), 'auth.json'); + +// Use saved session in future runs +const browser2 = new SentienceBrowser( + undefined, // apiKey + undefined, // apiUrl + false, // headless + undefined, // proxy + undefined, // userDataDir + 'auth.json' // storageState - inject saved session +); +await browser2.start(); +// Agent starts already logged in! + +// Workflow 2: Persistent sessions (cookies persist across runs) +const browser3 = new SentienceBrowser( + undefined, // apiKey + undefined, // apiUrl + false, // headless + undefined, // proxy + './chrome_profile', // userDataDir - persist cookies + undefined // storageState +); +await browser3.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.ts` for complete examples. + ## Best Practices ### 1. Wait for Dynamic Content diff --git a/examples/auth-injection-agent.ts b/examples/auth-injection-agent.ts new file mode 100644 index 00000000..42821181 --- /dev/null +++ b/examples/auth-injection-agent.ts @@ -0,0 +1,258 @@ +/** + * Example: Using Authentication Session Injection with Sentience SDK + * + * This example demonstrates how to inject pre-recorded authentication sessions + * (cookies + localStorage) into SentienceBrowser to start agents already logged in. + * + * 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 + * ts-node examples/auth-injection-agent.ts --storage-state auth.json + * + * # Workflow 2: Use persistent user data directory + * ts-node examples/auth-injection-agent.ts --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 { SentienceBrowser, SentienceAgent, OpenAIProvider, saveStorageState } from '../src'; +import * as fs from 'fs'; +import * as readline from 'readline'; + +async function exampleInjectStorageState() { + console.log('='.repeat(60)); + console.log('Example 1: Inject Pre-recorded Session'); + console.log('='.repeat(60)); + + const storageStateFile = 'auth.json'; + + if (!fs.existsSync(storageStateFile)) { + console.log(`\n⚠️ Storage state file not found: ${storageStateFile}`); + console.log('\n To create this file:'); + console.log(' 1. Log in manually to your target website'); + console.log(' 2. Use saveStorageState() to save the session'); + console.log('\n Example code:'); + console.log(' ```typescript'); + console.log(' import { SentienceBrowser, saveStorageState } from \'sentience-ts\';'); + console.log(' const browser = new SentienceBrowser();'); + console.log(' await browser.start();'); + console.log(' await browser.getPage().goto(\'https://example.com\');'); + console.log(' // ... log in manually ...'); + console.log(' await saveStorageState(browser.getContext(), \'auth.json\');'); + console.log(' ```'); + console.log('\n Skipping this example...\n'); + return; + } + + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey) { + console.error('❌ Error: OPENAI_API_KEY not set'); + return; + } + + // Create browser with storage state injection + const browser = new SentienceBrowser( + undefined, // apiKey + undefined, // apiUrl + false, // headless + undefined, // proxy + undefined, // userDataDir + storageStateFile // storageState - inject saved session + ); + + const llm = new OpenAIProvider(openaiKey, 'gpt-4o-mini'); + const agent = new SentienceAgent(browser, llm, 50, true); + + try { + console.log('\nπŸš€ Starting browser with injected session...'); + await browser.start(); + + console.log('🌐 Navigating to authenticated page...'); + // Agent starts already logged in! + await browser.getPage().goto('https://example.com/orders'); // Or your authenticated page + await browser.getPage().waitForLoadState('networkidle'); + + console.log('\nβœ… Browser started with pre-injected authentication!'); + console.log(' Agent can now access authenticated pages without logging in'); + + // Example: Use agent on authenticated pages + await agent.act('Show me my recent orders'); + await agent.act('Click on the first order'); + + console.log('\nβœ… Agent execution complete!'); + + } catch (error: any) { + console.error(`\n❌ Error: ${error.message}`); + throw error; + } finally { + await browser.close(); + } +} + +async function examplePersistentSession() { + console.log('='.repeat(60)); + console.log('Example 2: Persistent Session (User Data Directory)'); + console.log('='.repeat(60)); + + const userDataDir = './chrome_profile'; + + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey) { + console.error('❌ Error: OPENAI_API_KEY not set'); + return; + } + + // Create browser with persistent user data directory + const browser = new SentienceBrowser( + undefined, // apiKey + undefined, // apiUrl + false, // headless + undefined, // proxy + userDataDir, // userDataDir - persist cookies and localStorage + undefined // storageState + ); + + const llm = new OpenAIProvider(openaiKey, 'gpt-4o-mini'); + const agent = new SentienceAgent(browser, llm, 50, true); + + try { + console.log('\nπŸš€ Starting browser with persistent session...'); + await browser.start(); + + // Check if this is first run (no existing session) + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // First run: Agent needs to log in + // Second run: Agent is already logged in (cookies persist) + if (fs.existsSync(userDataDir)) { + console.log('\nβœ… Using existing session from previous run'); + console.log(' Cookies and localStorage are loaded automatically'); + } else { + console.log('\nπŸ“ First run - session will be saved after login'); + console.log(' Next run will automatically use saved session'); + } + + // Example: Log in (first run) or use existing session (subsequent runs) + await agent.act('Click the sign in button'); + await agent.act('Type your email into the email field'); + await agent.act('Type your password into the password field'); + await agent.act('Click the login button'); + + console.log('\nβœ… Session will persist in:', userDataDir); + console.log(' Next run will automatically use this session'); + + } catch (error: any) { + console.error(`\n❌ Error: ${error.message}`); + throw error; + } finally { + await browser.close(); + } +} + +async function exampleSaveStorageState() { + console.log('='.repeat(60)); + console.log('Example 3: Save Current Session'); + console.log('='.repeat(60)); + + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey) { + console.error('❌ Error: OPENAI_API_KEY not set'); + return; + } + + const browser = new SentienceBrowser(); + const llm = new OpenAIProvider(openaiKey, 'gpt-4o-mini'); + const agent = new SentienceAgent(browser, llm, 50, true); + + try { + console.log('\nπŸš€ Starting browser...'); + await browser.start(); + + console.log('🌐 Navigate to your target website and log in manually...'); + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + console.log('\n⏸️ Please log in manually in the browser window'); + console.log(' Press Enter when you\'re done logging in...'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + await new Promise(resolve => { + rl.question('', () => { + rl.close(); + resolve(); + }); + }); + + // Save the current session + const storageStateFile = 'auth.json'; + await saveStorageState(browser.getContext(), storageStateFile); + + console.log(`\nβœ… Session saved to: ${storageStateFile}`); + console.log(' You can now use this file with storageState parameter:'); + console.log(` const browser = new SentienceBrowser(..., '${storageStateFile}');`); + + } catch (error: any) { + console.error(`\n❌ Error: ${error.message}`); + throw error; + } finally { + await browser.close(); + } +} + +async function main() { + const args = process.argv.slice(2); + const storageStateArg = args.find(arg => arg.startsWith('--storage-state=')); + const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir=')); + const saveSession = args.includes('--save-session'); + + console.log('\n' + '='.repeat(60)); + console.log('Sentience SDK - Authentication Session Injection Examples'); + console.log('='.repeat(60) + '\n'); + + if (saveSession) { + await exampleSaveStorageState(); + } else if (storageStateArg) { + // Would need to modify example to use provided path + await exampleInjectStorageState(); + } else if (userDataDirArg) { + // Would need to modify example to use provided directory + await examplePersistentSession(); + } else { + // Run all examples + await exampleSaveStorageState(); + console.log('\n'); + await exampleInjectStorageState(); + console.log('\n'); + await examplePersistentSession(); + } + + console.log('\n' + '='.repeat(60)); + console.log('Examples Complete!'); + console.log('='.repeat(60)); + console.log('\nπŸ’‘ Tips:'); + console.log(' - Use storageState to inject pre-recorded sessions'); + console.log(' - Use userDataDir to persist sessions across runs'); + console.log(' - Save sessions after manual login for reuse'); + console.log(' - Bypass login screens and CAPTCHAs with valid sessions'); + console.log(' - Reduce token costs by skipping login steps\n'); +} + +main().catch(console.error); + diff --git a/src/browser.ts b/src/browser.ts index 60d281af..c03d56fd 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { URL } from 'url'; +import { StorageState } from './types'; export class SentienceBrowser { private context: BrowserContext | null = null; @@ -18,12 +19,16 @@ export class SentienceBrowser { private _apiUrl?: string; private headless: boolean; private _proxy?: string; + private _userDataDir?: string; + private _storageState?: string | StorageState | object; constructor( apiKey?: string, apiUrl?: string, headless?: boolean, - proxy?: string + proxy?: string, + userDataDir?: string, + storageState?: string | StorageState | object ) { this._apiKey = apiKey; @@ -45,6 +50,10 @@ export class SentienceBrowser { // Support proxy from parameter or environment variable this._proxy = proxy || process.env.SENTIENCE_PROXY; + + // Auth injection support + this._userDataDir = userDataDir; + this._storageState = storageState; } async start(): Promise { @@ -77,8 +86,18 @@ export class SentienceBrowser { ); } - // 2. Setup Temp Profile (Avoids locking issues) - this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentience-ts-')); + // 2. Setup User Data Directory + if (this._userDataDir) { + // Use provided directory for persistent sessions + this.userDataDir = this._userDataDir; + if (!fs.existsSync(this.userDataDir)) { + fs.mkdirSync(this.userDataDir, { recursive: true }); + } + } else { + // Create temp directory (ephemeral, existing behavior) + this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentience-ts-')); + } + this.extensionPath = path.join(this.userDataDir, 'extension'); // Copy extension to temp dir @@ -124,6 +143,11 @@ export class SentienceBrowser { this.page = this.context.pages()[0] || await this.context.newPage(); + // Inject storage state if provided (must be after context creation) + if (this._storageState) { + await this.injectStorageState(this._storageState); + } + // Apply context-level stealth patches (runs on every new page) await this.context.addInitScript(() => { // Early webdriver hiding - runs before any page script @@ -493,6 +517,111 @@ export class SentienceBrowser { } } + /** + * Inject storage state (cookies + localStorage) into browser context. + * + * @param storageState - Path to JSON file, StorageState object, or plain object + */ + private async injectStorageState( + storageState: string | StorageState | object + ): Promise { + // Load storage state + let state: StorageState; + + if (typeof storageState === 'string') { + // Load from file + const content = fs.readFileSync(storageState, 'utf-8'); + state = JSON.parse(content) as StorageState; + } else if (typeof storageState === 'object' && storageState !== null) { + // Already an object (StorageState or plain object) + state = storageState as StorageState; + } else { + throw new Error( + `Invalid storageState type: ${typeof storageState}. ` + + 'Expected string (file path), StorageState, or object.' + ); + } + + // Inject cookies (works globally) + if (state.cookies && Array.isArray(state.cookies) && state.cookies.length > 0) { + // Convert to Playwright cookie format + const playwrightCookies = state.cookies.map(cookie => { + const playwrightCookie: any = { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || '/', + }; + + if (cookie.expires !== undefined) { + playwrightCookie.expires = cookie.expires; + } + if (cookie.httpOnly !== undefined) { + playwrightCookie.httpOnly = cookie.httpOnly; + } + if (cookie.secure !== undefined) { + playwrightCookie.secure = cookie.secure; + } + if (cookie.sameSite !== undefined) { + playwrightCookie.sameSite = cookie.sameSite; + } + + return playwrightCookie; + }); + + await this.context!.addCookies(playwrightCookies); + console.log(`βœ… [Sentience] Injected ${state.cookies.length} cookie(s)`); + } + + // Inject LocalStorage (requires navigation to each domain) + if (state.origins && Array.isArray(state.origins)) { + for (const originData of state.origins) { + const origin = originData.origin; + if (!origin) { + continue; + } + + try { + // Navigate to origin + await this.page!.goto(origin, { waitUntil: 'domcontentloaded', timeout: 10000 }); + + // Inject localStorage + if (originData.localStorage && Array.isArray(originData.localStorage)) { + // Convert to dict format for JavaScript + const localStorageDict: Record = {}; + for (const item of originData.localStorage) { + localStorageDict[item.name] = item.value; + } + + await this.page!.evaluate((localStorageData: Record) => { + for (const [key, value] of Object.entries(localStorageData)) { + localStorage.setItem(key, value); + } + }, localStorageDict); + + console.log( + `βœ… [Sentience] Injected ${originData.localStorage.length} localStorage item(s) for ${origin}` + ); + } + } catch (error: any) { + console.warn( + `⚠️ [Sentience] Failed to inject localStorage for ${origin}: ${error.message}` + ); + } + } + } + } + + /** + * Get the browser context (for utilities like saveStorageState) + */ + getContext(): BrowserContext { + if (!this.context) { + throw new Error('Browser not started. Call start() first.'); + } + return this.context; + } + async close(): Promise { const cleanup: Promise[] = []; @@ -529,12 +658,17 @@ export class SentienceBrowser { this.extensionPath = null; } - // Clean up user data directory + // Clean up user data directory (only if it's a temp directory) + // If user provided a custom userDataDir, we don't delete it (persistent sessions) if (this.userDataDir && fs.existsSync(this.userDataDir)) { - try { - fs.rmSync(this.userDataDir, { recursive: true, force: true }); - } catch (e) { - // Ignore cleanup errors + // Only delete if it's a temp directory (starts with os.tmpdir()) + const isTempDir = this.userDataDir.startsWith(os.tmpdir()); + if (isTempDir) { + try { + fs.rmSync(this.userDataDir, { recursive: true, force: true }); + } catch (e) { + // Ignore cleanup errors + } } this.userDataDir = null; } diff --git a/src/index.ts b/src/index.ts index cd3d90a0..90f6910f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { ScriptGenerator, generate } from './generator'; export { read, ReadOptions, ReadResult } from './read'; export { screenshot, ScreenshotOptions } from './screenshot'; export * from './types'; +export { saveStorageState } from './utils'; // Agent Layer (v0.2.0+) export { diff --git a/src/types.ts b/src/types.ts index 9f2dbb6a..286e6134 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,5 +75,50 @@ export interface QuerySelectorObject { export type QuerySelector = string | QuerySelectorObject; +// ========== Storage State Types (Auth Injection) ========== + +/** + * Cookie definition for storage state injection. + * Matches Playwright's cookie format for storage_state. + */ +export interface Cookie { + name: string; + value: string; + domain: string; + path?: string; + expires?: number; // Unix timestamp + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +} + +/** + * LocalStorage item for a specific origin. + * Playwright stores localStorage as an array of {name, value} objects. + */ +export interface LocalStorageItem { + name: string; + value: string; +} + +/** + * Storage state for a specific origin (localStorage). + * Represents localStorage data for a single domain. + */ +export interface OriginStorage { + origin: string; + localStorage: LocalStorageItem[]; +} + +/** + * 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. + */ +export interface StorageState { + cookies: Cookie[]; + origins: OriginStorage[]; +} + diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..5f86e9b9 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,44 @@ +/** + * Utility functions for Sentience SDK + */ + +import { BrowserContext } from 'playwright'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Save current browser storage state (cookies + localStorage) to a file. + * + * This is useful for capturing a logged-in session to reuse later. + * + * @param context - Playwright BrowserContext + * @param filePath - Path to save the storage state JSON file + * + * @example + * ```typescript + * import { SentienceBrowser, saveStorageState } from 'sentience-ts'; + * + * const browser = new SentienceBrowser(); + * await browser.start(); + * + * // User logs in manually or via agent + * await browser.getPage().goto('https://example.com'); + * // ... login happens ... + * + * // Save session for later + * await saveStorageState(browser.getContext(), 'auth.json'); + * ``` + */ +export async function saveStorageState( + context: BrowserContext, + filePath: string +): Promise { + const storageState = await context.storageState(); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(storageState, null, 2)); + console.log(`βœ… [Sentience] Saved storage state to ${filePath}`); +} +