From b73df7bd864e177d2638f2e61196f8aea5c8a052 Mon Sep 17 00:00:00 2001 From: punagarwal0-ship-it Date: Sat, 12 Sep 2026 13:37:15 +0530 Subject: [PATCH 1/8] Add files via upload --- src/CONTRACT.md | 99 +++++++++++ src/adapter_base.ts | 12 ++ src/browser_manager.smoke.test.ts | 14 ++ src/browser_manager.ts | 141 ++++++++++++++++ src/client.ts | 144 ++++++++++++++++ src/error_handler.ts | 69 ++++++++ src/index (1).ts | 5 + src/index.ts | 3 + src/recovery.test.ts | 22 +++ src/recovery.ts | 68 ++++++++ src/result_schema.ts | 75 +++++++++ src/session.ts | 21 +++ src/skills.ts | 263 +++--------------------------- 13 files changed, 698 insertions(+), 238 deletions(-) create mode 100644 src/CONTRACT.md create mode 100644 src/adapter_base.ts create mode 100644 src/browser_manager.smoke.test.ts create mode 100644 src/browser_manager.ts create mode 100644 src/client.ts create mode 100644 src/error_handler.ts create mode 100644 src/index (1).ts create mode 100644 src/index.ts create mode 100644 src/recovery.test.ts create mode 100644 src/recovery.ts create mode 100644 src/result_schema.ts create mode 100644 src/session.ts diff --git a/src/CONTRACT.md b/src/CONTRACT.md new file mode 100644 index 000000000..7e6080926 --- /dev/null +++ b/src/CONTRACT.md @@ -0,0 +1,99 @@ +# PilgrimOS PC 2A Core Contract + +This contract is the seam between PC 2A (engine) and PC 2B (Temple / Travel / Hotel adapters). +Do not put domain logic in this package. + +## BrowserManager + +```ts +BrowserManager.startSession(name?) -> Promise +BrowserManager.navigate(session, url) -> Promise +BrowserManager.readPage(session) -> Promise +BrowserManager.closeSession(session) -> Promise +BrowserManager.pauseSession(session) -> void +``` + +`pauseSession()` is an in-memory safety boundary. While paused, navigation/read calls throw. It does not close or destroy the Webcmd session, allowing an upstream HITL flow to resume or clean up it safely. + +`PageState`: + +```ts +{ + url: string; + title: string; + content: string; + content_found: boolean; +} +``` + +## AdapterBase + +```ts +abstract class AdapterBase { + readonly adapter: string; + abstract run(input: TInput): Promise>; +} +``` + +A domain adapter should return `StandardResult` for every completed/failed/partial path and never leak raw Webcmd errors to callers. + +## StandardResult + +```json +{ + "success": true, + "status": "completed", + "adapter": "temple", + "action": "check_availability", + "data": {}, + "metadata": { + "source": "", + "timestamp": "" + }, + "error": null +} +``` + +Valid statuses are exactly: + +`completed | searching | partial | failed | retrying | blocked | approval_required` + +`error` is `null` on success and follows: + +```ts +{ + code: ErrorCode; + message: string; + retryable: boolean; + details?: unknown; +} +``` + +## Error codes + +- `NAVIGATION_FAILED` +- `TIMEOUT` +- `ELEMENT_NOT_FOUND` +- `PAGE_CHANGED` +- `WEBSITE_UNAVAILABLE` +- `RATE_LIMITED` +- `LOGIN_REQUIRED` +- `CAPTCHA_DETECTED` +- `NO_AVAILABILITY` +- `INVALID_INPUT` +- `UNKNOWN_ERROR` + +## Recovery + +```ts +RecoveryManager.retry(fn, policy) -> Promise +``` + +Default policy: 3 attempts, exponential backoff, bounded jitter. + +Dangerous actions are never meant to enter recovery. The manager also rejects a policy marked `dangerousAction: true` as a defense in depth check. Payment, OTP, and final submit must be short-circuited upstream to `approval_required`. + +## Webcmd boundary + +PC 2B should not call the Webcmd CLI directly. Use `BrowserManager` and/or `WebcmdSkills`. +The client follows the current Webcmd CLI lifecycle: create a session, run browser programs against that explicit session, then close it. diff --git a/src/adapter_base.ts b/src/adapter_base.ts new file mode 100644 index 000000000..493645942 --- /dev/null +++ b/src/adapter_base.ts @@ -0,0 +1,12 @@ +import type { StandardResult } from './result_schema'; + +export interface AdapterInput { + action: string; + [key: string]: unknown; +} + +export abstract class AdapterBase { + public abstract readonly adapter: string; + + public abstract run(input: TInput): Promise>; +} diff --git a/src/browser_manager.smoke.test.ts b/src/browser_manager.smoke.test.ts new file mode 100644 index 000000000..afdc4db85 --- /dev/null +++ b/src/browser_manager.smoke.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { BrowserManager } from '../src/adapters/core'; + +describe('PC2A BrowserManager smoke test', () => { + it('runs start -> navigate -> read -> structured JSON against a real page', async () => { + const browser = new BrowserManager(); + const result = await browser.runSmokeTest('https://example.com'); + + expect(result.success).toBe(true); + expect(result.status).toBe('completed'); + expect(result.data.title.length).toBeGreaterThan(0); + expect(result.data.content_found).toBe(true); + }, 120_000); +}); diff --git a/src/browser_manager.ts b/src/browser_manager.ts new file mode 100644 index 000000000..af5ac7f17 --- /dev/null +++ b/src/browser_manager.ts @@ -0,0 +1,141 @@ +import { ErrorHandler } from './error_handler'; +import { createResult, type StandardResult } from './result_schema'; +import { RecoveryManager, DEFAULT_RETRY_POLICY } from './recovery'; +import { WebcmdClient } from '../webcmd/client'; + +export interface BrowserSession { + readonly id: string; + readonly profile?: string; + readonly createdAt: string; + paused: boolean; + closed: boolean; +} + +export interface PageState { + url: string; + title: string; + content: string; + content_found: boolean; +} + +export class BrowserManager { + constructor( + private readonly client = new WebcmdClient(), + private readonly profile?: string, + ) {} + + async startSession(name = `pilgrimos-${Date.now()}`): Promise { + const created = await this.client.createSession(name, this.profile); + return { + id: created.id, + profile: this.profile, + createdAt: new Date().toISOString(), + paused: false, + closed: false, + }; + } + + async navigate(session: BrowserSession, url: string): Promise { + this.assertUsable(session); + if (!/^https?:\/\//i.test(url)) throw new Error('Invalid input: URL must start with http:// or https://'); + + const run = async () => { + const result = await this.client.browserRun( + session.id, + ` + await page.goto(${JSON.stringify(url)}, { waitUntil: 'domcontentloaded' }); + return { + url: page.url(), + title: await page.title(), + content: await page.locator('body').innerText(), + }; + `, + session.profile, + ); + return normalizePageState(result); + }; + + return RecoveryManager.retry(run, { + ...DEFAULT_RETRY_POLICY, + shouldRetry: (error) => ErrorHandler.isRetryable(ErrorHandler.classify(error)), + }); + } + + async readPage(session: BrowserSession): Promise { + this.assertUsable(session); + const result = await this.client.browserRun( + session.id, + `return { + url: page.url(), + title: await page.title(), + content: await page.locator('body').innerText(), + };`, + session.profile, + ); + return normalizePageState(result); + } + + async closeSession(session: BrowserSession): Promise { + if (session.closed) return; + await this.client.closeSession(session.id, session.profile); + session.closed = true; + session.paused = false; + } + + pauseSession(session: BrowserSession): void { + if (session.closed) throw new Error('Cannot pause a closed browser session.'); + session.paused = true; + } + + resumeSession(session: BrowserSession): void { + if (session.closed) throw new Error('Cannot resume a closed browser session.'); + session.paused = false; + } + + async runSmokeTest(url = 'https://example.com'): Promise> { + let session: BrowserSession | undefined; + try { + session = await this.startSession('pilgrimos-smoke'); + const page = await this.navigate(session, url); + return createResult({ + success: true, + status: 'completed', + adapter: 'website', + action: 'smoke_test', + data: page, + source: 'website', + }); + } catch (error) { + const standardError = ErrorHandler.toStandardError(error); + return createResult({ + success: false, + status: 'failed', + adapter: 'website', + action: 'smoke_test', + data: {} as PageState, + source: 'website', + error: standardError, + }); + } finally { + if (session) { + try { await this.closeSession(session); } catch { /* cleanup is best effort */ } + } + } + } + + private assertUsable(session: BrowserSession): void { + if (session.closed) throw new Error('Session is closed.'); + if (session.paused) throw new Error('Session is paused for human approval.'); + } +} + +function normalizePageState(raw: unknown): PageState { + const value = (raw && typeof raw === 'object') ? raw as Record : {}; + const content = typeof value.content === 'string' ? value.content : ''; + return { + url: typeof value.url === 'string' ? value.url : '', + title: typeof value.title === 'string' ? value.title : '', + content, + content_found: content.trim().length > 0, + }; +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 000000000..12ff888fc --- /dev/null +++ b/src/client.ts @@ -0,0 +1,144 @@ +import { spawn } from 'node:child_process'; + +export interface WebcmdExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface WebcmdClientOptions { + command?: string; + cwd?: string; + env?: Record; + timeoutMs?: number; +} + +export class WebcmdClient { + private readonly command: string; + private readonly cwd?: string; + private readonly env?: Record; + private readonly timeoutMs: number; + + constructor(options: WebcmdClientOptions = {}) { + this.command = options.command ?? 'webcmd'; + this.cwd = options.cwd; + this.env = options.env; + this.timeoutMs = options.timeoutMs ?? 120_000; + } + + async exec(args: string[], stdin?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.command, args, { + cwd: this.cwd, + env: { ...process.env, ...this.env }, + windowsHide: true, + stdio: 'pipe', + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + reject(new Error(`Webcmd command timed out after ${this.timeoutMs}ms: ${this.command} ${args.join(' ')}`)); + }, this.timeoutMs); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + + child.on('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const exitCode = code ?? 1; + if (exitCode !== 0) { + const message = stderr.trim() || stdout.trim() || `Webcmd exited with code ${exitCode}`; + reject(new Error(message)); + return; + } + resolve({ stdout, stderr, exitCode }); + }); + + if (stdin !== undefined) { + child.stdin.write(stdin); + } + child.stdin.end(); + }); + } + + async doctor(): Promise { + const result = await this.exec(['doctor']); + return result.stdout.trim(); + } + + async createSession(name: string, profile?: string): Promise<{ id: string; raw: unknown }> { + const args = profile ? ['--profile', profile, 'session', 'create', name, '-f', 'json'] : ['session', 'create', name, '-f', 'json']; + const result = await this.exec(args); + const raw = parseJsonLoose(result.stdout); + const id = findSessionId(raw, result.stdout); + if (!id) throw new Error(`Could not determine Webcmd session id from output: ${result.stdout}`); + return { id, raw }; + } + + async closeSession(sessionId: string, profile?: string): Promise { + const args = profile ? ['--profile', profile, 'session', 'close', sessionId] : ['session', 'close', sessionId]; + await this.exec(args); + } + + async browserRun(sessionId: string, script: string, profile?: string): Promise { + const prefix = profile ? ['--profile', profile, '--session', sessionId] : ['--session', sessionId]; + const result = await this.exec([...prefix, 'browser', 'run', '--stdin'], script); + return parseJsonLoose(result.stdout) ?? result.stdout.trim(); + } + + async browserTabs(sessionId: string, profile?: string): Promise { + const prefix = profile ? ['--profile', profile, '--session', sessionId] : ['--session', sessionId]; + const result = await this.exec([...prefix, 'browser', 'tabs']); + return parseJsonLoose(result.stdout) ?? result.stdout.trim(); + } +} + +function parseJsonLoose(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + const candidates = ['{', '['] + .map((c) => trimmed.indexOf(c)) + .filter((idx) => idx >= 0); + const start = candidates.length ? Math.min(...candidates) : Number.POSITIVE_INFINITY; + const end = Math.max(trimmed.lastIndexOf('}'), trimmed.lastIndexOf(']')); + if (Number.isFinite(start) && end >= start) { + try { return JSON.parse(trimmed.slice(start, end + 1)); } catch { return null; } + } + return null; + } +} + +function findSessionId(raw: unknown, fallback: string): string | null { + if (raw && typeof raw === 'object') { + const object = raw as Record; + for (const key of ['id', 'session_id', 'sessionId']) { + if (typeof object[key] === 'string') return object[key] as string; + } + for (const value of Object.values(object)) { + const nested = findSessionId(value, ''); + if (nested) return nested; + } + } + const match = fallback.match(/\bid\s*[:=]\s*([A-Za-z0-9._-]+)/i); + return match?.[1] ?? null; +} diff --git a/src/error_handler.ts b/src/error_handler.ts new file mode 100644 index 000000000..123c378ae --- /dev/null +++ b/src/error_handler.ts @@ -0,0 +1,69 @@ +import { ERROR_CODES, type ErrorCode, type StandardError } from './result_schema'; + +export { ERROR_CODES }; +export type { ErrorCode }; + +export class ErrorHandler { + static classify(rawError: unknown): ErrorCode { + if (!rawError) return 'UNKNOWN_ERROR'; + if (this.isErrorCode(rawError)) return rawError; + + const text = this.toText(rawError).toLowerCase(); + + if (this.matches(text, ['captcha', 'recaptcha', 'hcaptcha'])) return 'CAPTCHA_DETECTED'; + if (this.matches(text, ['rate limit', 'too many requests', '429'])) return 'RATE_LIMITED'; + if (this.matches(text, ['login required', 'sign in', 'log in', 'authentication required', 'unauthorized'])) return 'LOGIN_REQUIRED'; + if (this.matches(text, ['no availability', 'sold out', 'fully booked', 'no slots'])) return 'NO_AVAILABILITY'; + if (this.matches(text, ['timeout', 'timed out', 'deadline exceeded'])) return 'TIMEOUT'; + if (this.matches(text, ['element not found', 'locator', 'no such element', 'strict mode violation'])) return 'ELEMENT_NOT_FOUND'; + if (this.matches(text, ['page changed', 'stale element', 'target closed', 'execution context was destroyed'])) return 'PAGE_CHANGED'; + if (this.matches(text, ['dns', 'econnrefused', 'enotfound', '503', '502', '504', 'service unavailable', 'website unavailable'])) return 'WEBSITE_UNAVAILABLE'; + if (this.matches(text, ['navigation failed', 'navigation error', 'net::err', 'navigation timeout'])) return 'NAVIGATION_FAILED'; + if (this.matches(text, ['invalid input', 'validation failed', 'invalid argument'])) return 'INVALID_INPUT'; + + return 'UNKNOWN_ERROR'; + } + + static toStandardError(rawError: unknown, code?: ErrorCode): StandardError { + const classified = code ?? this.classify(rawError); + return { + code: classified, + message: this.toText(rawError), + retryable: this.isRetryable(classified), + details: this.safeDetails(rawError), + }; + } + + static isRetryable(code: ErrorCode): boolean { + return new Set([ + 'NAVIGATION_FAILED', + 'TIMEOUT', + 'PAGE_CHANGED', + 'WEBSITE_UNAVAILABLE', + 'RATE_LIMITED', + ]).has(code); + } + + private static isErrorCode(value: unknown): value is ErrorCode { + return typeof value === 'string' && (ERROR_CODES as readonly string[]).includes(value); + } + + private static matches(text: string, patterns: string[]): boolean { + return patterns.some((pattern) => text.includes(pattern)); + } + + private static toText(rawError: unknown): string { + if (rawError instanceof Error) return rawError.message || rawError.name; + if (typeof rawError === 'string') return rawError; + try { + return JSON.stringify(rawError); + } catch { + return String(rawError); + } + } + + private static safeDetails(rawError: unknown): unknown { + if (rawError instanceof Error) return { name: rawError.name, stack: rawError.stack }; + return rawError; + } +} diff --git a/src/index (1).ts b/src/index (1).ts new file mode 100644 index 000000000..fb707efef --- /dev/null +++ b/src/index (1).ts @@ -0,0 +1,5 @@ +export * from './adapter_base'; +export * from './browser_manager'; +export * from './error_handler'; +export * from './recovery'; +export * from './result_schema'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..d8ee2e68b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './client'; +export * from './session'; +export * from './skills'; diff --git a/src/recovery.test.ts b/src/recovery.test.ts new file mode 100644 index 000000000..6bd934772 --- /dev/null +++ b/src/recovery.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { DangerousActionError, RecoveryManager } from '../src/adapters/core'; + +describe('RecoveryManager', () => { + it('retries safe failures up to the default maximum', async () => { + let attempts = 0; + const value = await RecoveryManager.retry(async () => { + attempts += 1; + if (attempts < 3) throw new Error('temporary'); + return 'ok'; + }, { baseDelayMs: 0, maxDelayMs: 0, jitterRatio: 0 }); + + expect(value).toBe('ok'); + expect(attempts).toBe(3); + }); + + it('rejects dangerous actions', async () => { + await expect( + RecoveryManager.retry(async () => 'never', { dangerousAction: true }), + ).rejects.toBeInstanceOf(DangerousActionError); + }); +}); diff --git a/src/recovery.ts b/src/recovery.ts new file mode 100644 index 000000000..ff061aa09 --- /dev/null +++ b/src/recovery.ts @@ -0,0 +1,68 @@ +export interface RetryPolicy { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; + jitterRatio: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; + onRetry?: (error: unknown, nextAttempt: number, delayMs: number) => void | Promise; + dangerousAction?: boolean; +} + +export const DEFAULT_RETRY_POLICY: Readonly = Object.freeze({ + maxAttempts: 3, + baseDelayMs: 500, + maxDelayMs: 5000, + backoffMultiplier: 2, + jitterRatio: 0.2, + dangerousAction: false, +}); + +export class DangerousActionError extends Error { + constructor() { + super('Dangerous actions must be short-circuited to approval_required before RecoveryManager.retry().'); + this.name = 'DangerousActionError'; + } +} + +import { ErrorHandler } from './error_handler'; + +export class RecoveryManager { + static async retry( + fn: (attempt: number) => Promise, + policy: Partial = {}, + ): Promise { + const merged: RetryPolicy = { ...DEFAULT_RETRY_POLICY, ...policy }; + if (merged.dangerousAction) throw new DangerousActionError(); + + if (!Number.isInteger(merged.maxAttempts) || merged.maxAttempts < 1) { + throw new Error('Retry policy maxAttempts must be >= 1.'); + } + + let lastError: unknown; + for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) { + try { + return await fn(attempt); + } catch (error) { + lastError = error; + const canRetry = attempt < merged.maxAttempts && (merged.shouldRetry?.(error, attempt) ?? ErrorHandler.isRetryable(ErrorHandler.classify(error))); + if (!canRetry) throw error; + + const exponential = Math.min( + merged.maxDelayMs, + merged.baseDelayMs * Math.pow(merged.backoffMultiplier, attempt - 1), + ); + const jitter = exponential * merged.jitterRatio * (Math.random() * 2 - 1); + const delayMs = Math.max(0, Math.round(exponential + jitter)); + await merged.onRetry?.(error, attempt + 1, delayMs); + await this.sleep(delayMs); + } + } + + throw lastError instanceof Error ? lastError : new Error('Retry failed.'); + } + + private static sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/result_schema.ts b/src/result_schema.ts new file mode 100644 index 000000000..8e937ffc8 --- /dev/null +++ b/src/result_schema.ts @@ -0,0 +1,75 @@ +export const RESULT_STATUSES = [ + 'completed', + 'searching', + 'partial', + 'failed', + 'retrying', + 'blocked', + 'approval_required', +] as const; + +export type ResultStatus = (typeof RESULT_STATUSES)[number]; + +export interface ResultMetadata { + source: string; + timestamp: string; + [key: string]: unknown; +} + +export interface StandardResult { + success: boolean; + status: ResultStatus; + adapter: string; + action: string; + data: T; + metadata: ResultMetadata; + error: StandardError | null; +} + +export interface StandardError { + code: ErrorCode; + message: string; + retryable: boolean; + details?: unknown; +} + +export const ERROR_CODES = [ + 'NAVIGATION_FAILED', + 'TIMEOUT', + 'ELEMENT_NOT_FOUND', + 'PAGE_CHANGED', + 'WEBSITE_UNAVAILABLE', + 'RATE_LIMITED', + 'LOGIN_REQUIRED', + 'CAPTCHA_DETECTED', + 'NO_AVAILABILITY', + 'INVALID_INPUT', + 'UNKNOWN_ERROR', +] as const; + +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export function createResult(params: { + success: boolean; + status: ResultStatus; + adapter: string; + action: string; + data: T; + source?: string; + error?: StandardError | null; + metadata?: Record; +}): StandardResult { + return { + success: params.success, + status: params.status, + adapter: params.adapter, + action: params.action, + data: params.data, + metadata: { + source: params.source ?? '', + timestamp: new Date().toISOString(), + ...(params.metadata ?? {}), + }, + error: params.error ?? null, + }; +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 000000000..7cf3330b9 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,21 @@ +import { BrowserManager, type BrowserSession } from '../core/browser_manager'; + +export class WebcmdSessionManager { + constructor(private readonly browser: BrowserManager) {} + + async create(name?: string): Promise { + return this.browser.startSession(name); + } + + pauseForApproval(session: BrowserSession): void { + this.browser.pauseSession(session); + } + + resumeAfterApproval(session: BrowserSession): void { + this.browser.resumeSession(session); + } + + async cleanup(session: BrowserSession): Promise { + await this.browser.closeSession(session); + } +} diff --git a/src/skills.ts b/src/skills.ts index 29cfe275f..841b96716 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,248 +1,35 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import yaml from 'js-yaml'; -import { ArgumentError } from './errors.js'; -import { findPackageRoot } from './package-paths.js'; +import type { BrowserSession } from '../core/browser_manager'; +import { WebcmdClient } from './client'; -const MODULE_FILE = fileURLToPath(import.meta.url); +export class WebcmdSkills { + constructor(private readonly client = new WebcmdClient()) {} -export interface WebcmdSkillInfo { - name: string; - description: string; - version: string; - path: string; -} - -export interface WebcmdSkillOptions { - provider?: string; - scope?: string; - customPath?: string; - packageRoot?: string; - homeDir?: string; - cwd?: string; -} - -export interface WebcmdSkillLink { - name: string; - source: string; - stableLink: string; - destination?: string; -} - -export interface WebcmdSkillAddResult { - provider?: SkillProvider; - scope?: SkillScope; - skills: WebcmdSkillLink[]; -} - -export interface WebcmdSkillRemoveResult { - provider?: SkillProvider; - scope?: SkillScope; - removed: string[]; -} - -interface SkillFrontmatter { - name?: unknown; - description?: unknown; - version?: unknown; -} - -type SkillProvider = 'agents' | 'codex' | 'claude'; -type SkillScope = 'user' | 'project'; - -export function getSkillsRoot(packageRoot: string = findPackageRoot(MODULE_FILE)): string { - return path.join(packageRoot, 'skills'); -} - -export function listWebcmdSkills(packageRoot?: string): WebcmdSkillInfo[] { - const skillsRoot = getSkillsRoot(packageRoot); - if (!fs.existsSync(skillsRoot)) return []; - - return fs.readdirSync(skillsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => readSkillInfo(skillsRoot, entry.name)) - .filter((entry): entry is WebcmdSkillInfo => entry !== null) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -export function addWebcmdSkills(options: WebcmdSkillOptions = {}): WebcmdSkillAddResult { - const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined; - const scope = normalizeScope(options.scope); - const skills = updateStableSkillLinks(options) - .map((skill) => { - const destination = destinationFor(skill.name, provider, scope, options); - replaceDirectorySymlink(skill.stableLink, destination); - return { ...skill, destination }; - }); - return { provider, scope, skills }; -} - -export function updateWebcmdSkill(options: WebcmdSkillOptions = {}): WebcmdSkillAddResult { - const skills = updateStableSkillLinks(options); - if (options.provider === undefined && options.scope === undefined && options.customPath === undefined) return { skills }; - - const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined; - const scope = normalizeScope(options.scope); - return { - provider, - scope, - skills: skills.map((skill) => { - const destination = destinationFor(skill.name, provider, scope, options); - replaceDirectorySymlink(skill.stableLink, destination); - return { ...skill, destination }; - }), - }; -} - -export function removeWebcmdSkills(options: WebcmdSkillOptions = {}): WebcmdSkillRemoveResult { - const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined; - const scope = normalizeScope(options.scope); - const skills = listWebcmdSkills(options.packageRoot); - const removed: string[] = []; - - for (const skill of skills) { - const linkPath = destinationFor(skill.name, provider, scope, options); - const current = safeLstat(linkPath); - if (!current) continue; - if (!current.isSymbolicLink()) { - throw new ArgumentError(`Refusing to remove non-symlink path: ${linkPath}`, 'Remove it manually if it is no longer needed.'); - } - removed.push(linkPath); - } - - for (const linkPath of removed) fs.unlinkSync(linkPath); - return { provider, scope, removed }; -} - -function updateStableSkillLinks(options: WebcmdSkillOptions): WebcmdSkillLink[] { - const skillsRoot = getSkillsRoot(options.packageRoot); - const skills = listWebcmdSkills(options.packageRoot); - if (skills.length === 0) { - throw new ArgumentError(`No Webcmd skills found: ${skillsRoot}`, 'Install a package that includes skills/*/SKILL.md.'); + async navigate(session: BrowserSession, url: string): Promise { + return this.client.browserRun( + session.id, + `await page.goto(${JSON.stringify(url)}, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() };`, + session.profile, + ); } - const stableRoot = path.join(options.homeDir ?? os.homedir(), '.webcmd', 'skills'); - const currentNames = new Set(skills.map((skill) => skill.name)); - const links = skills.map((skill) => { - const source = path.join(skillsRoot, skill.name); - const stableLink = path.join(stableRoot, skill.name); - replaceDirectorySymlink(source, stableLink); - return { name: skill.name, source, stableLink }; - }); - - if (fs.existsSync(stableRoot)) { - for (const entry of fs.readdirSync(stableRoot, { withFileTypes: true })) { - if (currentNames.has(entry.name)) continue; - if (entry.isSymbolicLink()) fs.unlinkSync(path.join(stableRoot, entry.name)); - } + async extractText(session: BrowserSession, selector = 'body'): Promise { + const result = await this.client.browserRun( + session.id, + `return await page.locator(${JSON.stringify(selector)}).innerText();`, + session.profile, + ); + return typeof result === 'string' ? result : JSON.stringify(result); } - return links; -} - -function destinationFor(name: string, provider: SkillProvider | undefined, scope: SkillScope, options: WebcmdSkillOptions): string { - if (options.customPath !== undefined) return path.join(expandHomePath(options.customPath), name); - const base = scope === 'project' ? options.cwd ?? process.cwd() : options.homeDir ?? os.homedir(); - const agentDir = provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.agents'; - return path.join(base, agentDir, 'skills', name); -} - -function expandHomePath(raw: string): string { - const value = raw.trim(); - if (!value) throw new ArgumentError('Custom skills path must be non-empty.'); - return path.resolve(value === '~' ? os.homedir() : value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value); -} - -function normalizeProvider(raw = 'agents'): SkillProvider { - const value = raw.trim().toLowerCase(); - if (value === 'agents' || value === 'codex') return value; - if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude'; - throw new ArgumentError(`Unsupported skill provider: ${raw}`, 'Use one of: agents, codex, claude.'); -} - -function normalizeScope(raw = 'user'): SkillScope { - const value = raw.trim().toLowerCase(); - if (value === 'user' || value === 'global') return 'user'; - if (value === 'project' || value === 'local') return 'project'; - throw new ArgumentError(`Unsupported skill scope: ${raw}`, 'Use one of: user, global, project, local.'); -} - -function replaceDirectorySymlink(target: string, linkPath: string): void { - const current = safeLstat(linkPath); - if (current) { - if (!current.isSymbolicLink()) { - throw new ArgumentError(`Refusing to replace non-symlink path: ${linkPath}`, 'Remove it manually or choose a different scope/provider.'); - } - fs.unlinkSync(linkPath); + async extractJson(session: BrowserSession, script: string): Promise { + return (await this.client.browserRun(session.id, script, session.profile)) as T; } - fs.mkdirSync(path.dirname(linkPath), { recursive: true }); - fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); -} - -function safeLstat(filePath: string): fs.Stats | null { - try { - return fs.lstatSync(filePath); - } catch (err) { - if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') return null; - throw err; - } -} - -function readSkillInfo(skillsRoot: string, name: string): WebcmdSkillInfo | null { - const skillMdPath = path.join(skillsRoot, name, 'SKILL.md'); - if (!fs.existsSync(skillMdPath)) return null; - const content = fs.readFileSync(skillMdPath, 'utf8'); - const fm = parseFrontmatter(content); - return { - name: typeof fm.name === 'string' && fm.name ? fm.name : name, - description: typeof fm.description === 'string' ? fm.description : firstBodyParagraph(content), - version: typeof fm.version === 'string' || typeof fm.version === 'number' ? String(fm.version) : '', - path: `${name}/SKILL.md`, - }; -} - -function parseFrontmatter(content: string): SkillFrontmatter { - if (!content.startsWith('---\n')) return {}; - const end = content.indexOf('\n---', 4); - if (end < 0) return {}; - try { - const parsed = yaml.load(content.slice(4, end)); - return parsed && typeof parsed === 'object' ? parsed as SkillFrontmatter : {}; - } catch { - return parseLooseFrontmatter(content.slice(4, end)); - } -} - -function parseLooseFrontmatter(raw: string): SkillFrontmatter { - const out: Record = {}; - for (const line of raw.split('\n')) { - const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line); - if (!match) continue; - const [, key, value] = match; - if (!['name', 'description', 'version'].includes(key)) continue; - out[key] = value.trim().replace(/^['"]|['"]$/g, ''); - } - return out; -} - -function firstBodyParagraph(content: string): string { - const body = content.startsWith('---\n') - ? content.slice(Math.max(content.indexOf('\n---', 4) + 4, 0)) - : content; - const paragraph = body - .split(/\n\s*\n/) - .map((part) => part.replace(/^#+\s*/gm, '').trim()) - .find(Boolean); - return paragraph ?? ''; -} - -function isDirectory(filePath: string): boolean { - try { - return fs.statSync(filePath).isDirectory(); - } catch { - return false; + async waitForSelector(session: BrowserSession, selector: string, timeoutMs = 15_000): Promise { + await this.client.browserRun( + session.id, + `await page.locator(${JSON.stringify(selector)}).waitFor({ state: 'visible', timeout: ${timeoutMs} }); return true;`, + session.profile, + ); } } From ae3021833d7d3793cc9b867366f0dfd93b3e0302 Mon Sep 17 00:00:00 2001 From: vaishnaviasati21 Date: Sat, 12 Sep 2026 13:43:51 +0530 Subject: [PATCH 2/8] Add adapters --- package-lock.json | 7 - src/adapters/core/adapter_base.ts | 25 ++ src/adapters/core/browser_manager.ts | 204 ++++++++++ src/adapters/core/core-mock.ts | 157 +++++++ src/adapters/core/core.test.ts | 449 +++++++++++++++++++++ src/adapters/core/error_handler.ts | 142 +++++++ src/adapters/core/hitl-guard.ts | 156 +++++++ src/adapters/core/recovery.ts | 168 ++++++++ src/adapters/core/result_schema.ts | 108 +++++ src/adapters/core/types.ts | 114 ++++++ src/adapters/hotel/hotel-adapter.ts | 140 +++++++ src/adapters/index.ts | 13 + src/adapters/temple/availability.ts | 105 +++++ src/adapters/temple/crowd-estimator.ts | 87 ++++ src/adapters/temple/temple-adapter.test.ts | 257 ++++++++++++ src/adapters/temple/temple-adapter.ts | 198 +++++++++ src/adapters/temple/types.ts | 38 ++ src/adapters/travel/travel-adapter.ts | 141 +++++++ 18 files changed, 2502 insertions(+), 7 deletions(-) create mode 100644 src/adapters/core/adapter_base.ts create mode 100644 src/adapters/core/browser_manager.ts create mode 100644 src/adapters/core/core-mock.ts create mode 100644 src/adapters/core/core.test.ts create mode 100644 src/adapters/core/error_handler.ts create mode 100644 src/adapters/core/hitl-guard.ts create mode 100644 src/adapters/core/recovery.ts create mode 100644 src/adapters/core/result_schema.ts create mode 100644 src/adapters/core/types.ts create mode 100644 src/adapters/hotel/hotel-adapter.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/temple/availability.ts create mode 100644 src/adapters/temple/crowd-estimator.ts create mode 100644 src/adapters/temple/temple-adapter.test.ts create mode 100644 src/adapters/temple/temple-adapter.ts create mode 100644 src/adapters/temple/types.ts create mode 100644 src/adapters/travel/travel-adapter.ts diff --git a/package-lock.json b/package-lock.json index 6263deb9c..92e711830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -199,7 +199,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -246,7 +245,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -278,7 +276,6 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2679,7 +2676,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2692,7 +2688,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -3050,7 +3045,6 @@ "integrity": "sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3115,7 +3109,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", diff --git a/src/adapters/core/adapter_base.ts b/src/adapters/core/adapter_base.ts new file mode 100644 index 000000000..afc28e113 --- /dev/null +++ b/src/adapters/core/adapter_base.ts @@ -0,0 +1,25 @@ +import { StandardResult } from './result_schema.js'; + +/** + * Base abstract class for all PC2 adapters. + * Guarantees that any adapter implements run(input) -> Promise. + */ +export abstract class AdapterBase> { + readonly adapterName?: string; + + /** + * Primary entry point executing an adapter flow. + * + * @param input Typed input specific to the adapter action. + * @returns Clean StandardResult matching the exact contract. + */ + abstract run(input: TInput): Promise>; +} + +/** + * Interface representation of AdapterBase for callers preferring interface contracts. + */ +export interface IAdapterBase> { + readonly adapterName?: string; + run(input: TInput): Promise>; +} diff --git a/src/adapters/core/browser_manager.ts b/src/adapters/core/browser_manager.ts new file mode 100644 index 000000000..367da8088 --- /dev/null +++ b/src/adapters/core/browser_manager.ts @@ -0,0 +1,204 @@ +import type { IPage } from '../../types.js'; +import type { IBrowserFactory } from '../../runtime.js'; +import { BrowserBridge } from '../../browser/bridge.js'; + +export interface PageState { + url: string; + title: string; + content: string; // safe text snippet, not full raw DOM + isSensitive: boolean; + sensitiveReason?: string; + isPaused: boolean; +} + +export interface BrowserSession { + readonly id: string; + page?: IPage; + factory?: IBrowserFactory; + isClosed: boolean; + isPaused: boolean; + currentUrl?: string; +} + +export interface BrowserManagerOptions { + factory?: IBrowserFactory; + sessionTimeoutMs?: number; +} + +/** + * BrowserManager drives browser sessions using Webcmd's browser infrastructure. + * Exposes exactly: startSession, navigate, closeSession, pauseSession. + */ +export class BrowserManager { + private factorySupplier: () => IBrowserFactory; + private activeSessions = new Map(); + + constructor(options?: BrowserManagerOptions) { + if (options?.factory) { + const injectedFactory = options.factory; + this.factorySupplier = () => injectedFactory; + } else { + this.factorySupplier = () => new BrowserBridge(); + } + } + + /** + * Starts a new browser session via Webcmd client layer. + */ + async startSession(): Promise { + const sessionId = `webcmd-session-${Math.random().toString(36).substring(2, 9)}`; + const factory = this.factorySupplier(); + + let page: IPage | undefined; + try { + page = await factory.connect({ session: sessionId, surface: 'adapter' }); + } catch { + // In mock/test environments without daemon, page can be stubbed or injected + } + + const session: BrowserSession = { + id: sessionId, + page, + factory, + isClosed: false, + isPaused: false, + currentUrl: 'about:blank', + }; + + this.activeSessions.set(sessionId, session); + return session; + } + + /** + * Navigates the given session to the target URL and returns safe PageState. + */ + async navigate(session: BrowserSession, url: string): Promise { + if (session.isClosed) { + throw new Error(`Session ${session.id} is closed. Cannot navigate to ${url}`); + } + if (session.isPaused) { + throw new Error(`Session ${session.id} is paused. Resume before navigating.`); + } + + session.currentUrl = url; + + let title = ''; + let content = ''; + + if (session.page) { + await session.page.goto(url, { waitUntil: 'load' }); + + // Extract safe title and text preview (not full DOM dump) + try { + title = await session.page.evaluate(() => document.title || ''); + } catch { + title = ''; + } + + try { + content = await session.page.evaluate(() => { + if (!document.body) return ''; + return document.body.innerText ? document.body.innerText.slice(0, 2000) : ''; + }); + } catch { + content = ''; + } + } + + // Safety check for sensitive controls (OTP, payment, checkout, etc.) + const sensitiveInspection = this.inspectForSensitiveControls(url, title, content); + + const pageState: PageState = { + url, + title, + content, + isSensitive: sensitiveInspection.isSensitive, + sensitiveReason: sensitiveInspection.reason, + isPaused: session.isPaused, + }; + + // If sensitive page detected, immediately pause session + if (pageState.isSensitive) { + await this.pauseSession(session); + pageState.isPaused = true; + } + + return pageState; + } + + /** + * Pauses the given session to preserve state for Human-In-The-Loop. + */ + async pauseSession(session: BrowserSession): Promise { + session.isPaused = true; + } + + /** + * Closes and cleans up the browser session. + */ + async closeSession(session: BrowserSession): Promise { + session.isClosed = true; + if (session.page && typeof (session.page as unknown as Record).closeWindow === 'function') { + await (session.page as unknown as { closeWindow: () => Promise }).closeWindow().catch(() => {}); + } + if (session.factory) { + await session.factory.close().catch(() => {}); + } + this.activeSessions.delete(session.id); + } + + /** + * Helper to inspect if a page has sensitive indicators. + */ + private inspectForSensitiveControls( + url: string, + title: string, + content: string, + ): { isSensitive: boolean; reason?: string } { + const lowerUrl = url.toLowerCase(); + const lowerText = `${title} ${content}`.toLowerCase(); + + // 1. Payment & checkout + if ( + /\/pay(\/|$|\?)/i.test(lowerUrl) || + /\/checkout(\/|$|\?)/i.test(lowerUrl) || + /razorpay|billdesk|paytm|ccavenue/i.test(lowerUrl) + ) { + return { isSensitive: true, reason: 'Payment gateway or checkout URL detected' }; + } + if ( + /\b(enter card number|cvv|upi pin|upi qr|net banking|amount payable)\b/i.test( + lowerText, + ) + ) { + return { isSensitive: true, reason: 'Payment form elements detected' }; + } + + // 2. OTP & 2FA + if (/\/otp(\/|$|\?)/i.test(lowerUrl) || /\/verify(-otp)?(\/|$|\?)/i.test(lowerUrl)) { + return { isSensitive: true, reason: 'OTP verification URL detected' }; + } + if (/\b(enter otp|one-time password|verify otp|resend otp)\b/i.test(lowerText)) { + return { isSensitive: true, reason: 'OTP input detected in page content' }; + } + + // 3. Final submit / Commit + if ( + /\b(pay now|confirm and pay|confirm & pay|complete booking|authorize transaction)\b/i.test( + lowerText, + ) + ) { + return { isSensitive: true, reason: 'Final commitment/payment button detected' }; + } + + // 4. Captcha + if ( + /\b(i'm not a robot|verify you are human|recaptcha|hcaptcha)\b/i.test(lowerText) || + /\/captcha/i.test(lowerUrl) + ) { + return { isSensitive: true, reason: 'Captcha challenge detected' }; + } + + return { isSensitive: false }; + } +} diff --git a/src/adapters/core/core-mock.ts b/src/adapters/core/core-mock.ts new file mode 100644 index 000000000..61e0aeea9 --- /dev/null +++ b/src/adapters/core/core-mock.ts @@ -0,0 +1,157 @@ +import { + IBrowserManager, + IBrowserSession, + IErrorHandler, + ClassifiedError, + SessionOptions, +} from './types.js'; + +/** + * Controllable mock browser session for adapter development and testing. + * Simulates page content, URL navigation, evaluation, and session states. + */ +export class MockBrowserSession implements IBrowserSession { + public readonly id: string; + private _isClosed = false; + private _isPaused = false; + private _currentUrl = 'about:blank'; + private _pageContent = ''; + private _evaluateHandler?: (fn: () => R) => R; + public navigationHistory: string[] = []; + + constructor(id?: string) { + this.id = id || `mock-session-${Math.random().toString(36).substring(2, 9)}`; + } + + get isClosed(): boolean { + return this._isClosed; + } + + get isPaused(): boolean { + return this._isPaused; + } + + public setPageContent(content: string): void { + this._pageContent = content; + } + + public setCurrentUrl(url: string): void { + this._currentUrl = url; + } + + public setEvaluateHandler(handler: (fn: () => R) => R): void { + this._evaluateHandler = handler; + } + + async navigate(url: string): Promise { + if (this._isClosed) { + throw new Error(`Session ${this.id} is closed. Cannot navigate to ${url}`); + } + this._currentUrl = url; + this.navigationHistory.push(url); + } + + async getCurrentUrl(): Promise { + return this._currentUrl; + } + + async getPageContent(): Promise { + return this._pageContent; + } + + async evaluate(fn: () => R): Promise { + if (this._isClosed) { + throw new Error(`Session ${this.id} is closed. Cannot evaluate expression.`); + } + if (this._evaluateHandler) { + return this._evaluateHandler(fn); + } + return fn(); + } + + async pause(): Promise { + this._isPaused = true; + } + + async resume(): Promise { + this._isPaused = false; + } + + async close(): Promise { + this._isClosed = true; + } +} + +/** + * Mock browser manager implementing IBrowserManager. + * Drop-in replacement until PC2A real browser engine is provided. + */ +export class MockBrowserManager implements IBrowserManager { + private activeSessions = new Map(); + private nextSessionConfigurator?: (session: MockBrowserSession) => void; + + /** + * Helper to configure the next created session (useful for test setups). + */ + public configureNextSession(configurator: (session: MockBrowserSession) => void): void { + this.nextSessionConfigurator = configurator; + } + + async startSession(options?: SessionOptions): Promise { + const session = new MockBrowserSession(); + if (this.nextSessionConfigurator) { + this.nextSessionConfigurator(session); + this.nextSessionConfigurator = undefined; + } + this.activeSessions.set(session.id, session); + return session; + } + + async closeSession(sessionId: string): Promise { + const session = this.activeSessions.get(sessionId); + if (session) { + await session.close(); + this.activeSessions.delete(sessionId); + } + } + + public getActiveSessionsCount(): number { + return this.activeSessions.size; + } + + public getSession(sessionId: string): MockBrowserSession | undefined { + return this.activeSessions.get(sessionId); + } +} + +/** + * Mock error handler implementing IErrorHandler. + * Classifies errors into retryable (transient network, timeouts) vs fatal. + */ +export class MockErrorHandler implements IErrorHandler { + classify(error: unknown): ClassifiedError { + const err = error as Record | undefined; + const message = (err && typeof err.message === 'string') ? err.message : String(error); + const code = (err && typeof err.code === 'string') ? err.code : 'UNKNOWN_ERROR'; + + const isTimeout = + code === 'ETIMEDOUT' || + code === 'ECONNRESET' || + code === 'NETWORK_TIMEOUT' || + /timeout|temporarily unavailable|rate limit/i.test(message); + + const isPortalUnavailable = + code === 'HTTP_503' || + code === 'HTTP_502' || + /bad gateway|service unavailable/i.test(message); + + const retryable = isTimeout || isPortalUnavailable; + + return { + code, + message, + retryable, + originalError: error, + }; + } +} diff --git a/src/adapters/core/core.test.ts b/src/adapters/core/core.test.ts new file mode 100644 index 000000000..074e43805 --- /dev/null +++ b/src/adapters/core/core.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + StandardResult, + StandardResultStatus, + createSuccessResult, + createApprovalRequiredResult, + createFailureResult, +} from './result_schema.js'; +import { AdapterBase, IAdapterBase } from './adapter_base.js'; +import { BrowserManager, BrowserSession, PageState } from './browser_manager.js'; +import { + ErrorHandler, + ErrorCode, + NAVIGATION_FAILED, + TIMEOUT, + ELEMENT_NOT_FOUND, + PAGE_CHANGED, + WEBSITE_UNAVAILABLE, + RATE_LIMITED, + LOGIN_REQUIRED, + CAPTCHA_DETECTED, + NO_AVAILABILITY, + INVALID_INPUT, + UNKNOWN_ERROR, +} from './error_handler.js'; +import { RecoveryManager, RetryResult } from './recovery.js'; + +describe('Milestone 2: Reusable PC2A Core Foundation', () => { + describe('1. result_schema.ts', () => { + it('satisfies the exact StandardResult contract', () => { + const validStatuses: StandardResultStatus[] = [ + 'completed', + 'searching', + 'partial', + 'failed', + 'retrying', + 'blocked', + 'approval_required', + ]; + + for (const status of validStatuses) { + const result: StandardResult<{ query: string }> = { + success: status === 'completed', + status, + adapter: 'travel', + action: 'search_trains', + data: status === 'completed' ? { query: 'NDLS to BSB' } : null, + metadata: { + source: 'webcmd', + timestamp: new Date().toISOString(), + }, + error: status === 'failed' ? { code: 'FAIL', message: 'failed' } : null, + }; + + expect(typeof result.success).toBe('boolean'); + expect(validStatuses).toContain(result.status); + expect(result.adapter).toBe('travel'); + expect(result.action).toBe('search_trains'); + expect(result.metadata.source).toBe('webcmd'); + expect(typeof result.metadata.timestamp).toBe('string'); + } + }); + + it('creates success results via createSuccessResult helper', () => { + const data = { trainNo: '12301', seatsAvailable: 42 }; + const res = createSuccessResult('travel', 'check_seats', data); + + expect(res.success).toBe(true); + expect(res.status).toBe('completed'); + expect(res.adapter).toBe('travel'); + expect(res.action).toBe('check_seats'); + expect(res.data).toEqual(data); + expect(res.metadata.source).toBe('webcmd'); + expect(res.error).toBeNull(); + }); + + it('creates approval_required results via createApprovalRequiredResult helper', () => { + const res = createApprovalRequiredResult('temple', 'vip_darshan', 'OTP verification wall detected'); + + expect(res.success).toBe(false); + expect(res.status).toBe('approval_required'); + expect(res.adapter).toBe('temple'); + expect(res.action).toBe('vip_darshan'); + expect(res.data).toBeNull(); + expect(res.metadata.approvalReason).toContain('OTP'); + expect(res.error?.code).toBe('APPROVAL_REQUIRED'); + }); + + it('creates failure results via createFailureResult helper', () => { + const res = createFailureResult('hotel', 'search_rooms', 'TIMEOUT', 'Network socket timed out'); + + expect(res.success).toBe(false); + expect(res.status).toBe('failed'); + expect(res.adapter).toBe('hotel'); + expect(res.action).toBe('search_rooms'); + expect(res.data).toBeNull(); + expect(res.error?.code).toBe('TIMEOUT'); + expect(res.error?.message).toBe('Network socket timed out'); + }); + }); + + describe('2. adapter_base.ts', () => { + it('allows extending AdapterBase and invoking run(input)', async () => { + interface TestInput { + location: string; + } + interface TestOutput { + places: string[]; + } + + class TestAdapter extends AdapterBase { + readonly adapterName = 'test_explorer'; + + async run(input: TestInput): Promise> { + return createSuccessResult(this.adapterName, 'explore', { + places: [`Destination at ${input.location}`], + }); + } + } + + const adapter = new TestAdapter(); + const output = await adapter.run({ location: 'Varanasi' }); + + expect(output.success).toBe(true); + expect(output.adapter).toBe('test_explorer'); + expect(output.data?.places).toEqual(['Destination at Varanasi']); + }); + + it('satisfies IAdapterBase interface signature', async () => { + const mockAdapter: IAdapterBase<{ id: number }, { found: boolean }> = { + adapterName: 'mock_adapter', + run: async (input) => createSuccessResult('mock_adapter', 'find', { found: input.id > 0 }), + }; + + const res = await mockAdapter.run({ id: 10 }); + expect(res.success).toBe(true); + expect(res.data?.found).toBe(true); + }); + }); + + describe('3. browser_manager.ts', () => { + it('manages sessions using Webcmd client layer mocks: startSession, navigate, pauseSession, closeSession', async () => { + const mockPage = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation((fn: () => unknown) => { + const fnStr = fn.toString(); + if (fnStr.includes('document.title')) return 'Official Information Portal'; + if (fnStr.includes('innerText')) return 'Welcome to the public portal for darshan and schedules.'; + return ''; + }), + closeWindow: vi.fn().mockResolvedValue(undefined), + }; + + const mockFactory = { + connect: vi.fn().mockResolvedValue(mockPage), + close: vi.fn().mockResolvedValue(undefined), + }; + + const manager = new BrowserManager({ factory: mockFactory as any }); + + // 1. startSession + const session: BrowserSession = await manager.startSession(); + expect(session.id).toMatch(/^webcmd-session-/); + expect(session.isClosed).toBe(false); + expect(session.isPaused).toBe(false); + expect(mockFactory.connect).toHaveBeenCalledWith({ session: session.id, surface: 'adapter' }); + + // 2. navigate + const pageState: PageState = await manager.navigate(session, 'https://temple-darshan.gov.in'); + expect(mockPage.goto).toHaveBeenCalledWith('https://temple-darshan.gov.in', { waitUntil: 'load' }); + expect(pageState.url).toBe('https://temple-darshan.gov.in'); + expect(pageState.title).toBe('Official Information Portal'); + expect(pageState.content).toContain('Welcome to the public portal'); + expect(pageState.isSensitive).toBe(false); + expect(pageState.isPaused).toBe(false); + + // 3. pauseSession + await manager.pauseSession(session); + expect(session.isPaused).toBe(true); + + // Attempting to navigate while paused throws + await expect(manager.navigate(session, 'https://temple-darshan.gov.in/schedule')).rejects.toThrow( + /is paused/i, + ); + + // 4. closeSession + await manager.closeSession(session); + expect(session.isClosed).toBe(true); + expect(mockPage.closeWindow).toHaveBeenCalled(); + expect(mockFactory.close).toHaveBeenCalled(); + + // Attempting to navigate after close throws + await expect(manager.navigate(session, 'https://temple-darshan.gov.in')).rejects.toThrow( + /is closed/i, + ); + }); + + it('detects sensitive payment/checkout/OTP pages and automatically pauses session', async () => { + const mockPage = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation((fn: () => unknown) => { + const fnStr = fn.toString(); + if (fnStr.includes('document.title')) return 'Payment Gateway - Razorpay'; + if (fnStr.includes('innerText')) return 'Amount Payable: Rs 500. Enter card number and CVV.'; + return ''; + }), + }; + + const mockFactory = { + connect: vi.fn().mockResolvedValue(mockPage), + close: vi.fn().mockResolvedValue(undefined), + }; + + const manager = new BrowserManager({ factory: mockFactory as any }); + const session = await manager.startSession(); + + const state = await manager.navigate(session, 'https://temple.gov.in/checkout/pay'); + expect(state.isSensitive).toBe(true); + expect(state.sensitiveReason).toContain('Payment'); + expect(state.isPaused).toBe(true); + expect(session.isPaused).toBe(true); + }); + + it('detects OTP verification challenges in page text and automatically pauses session', async () => { + const mockPage = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation((fn: () => unknown) => { + const fnStr = fn.toString(); + if (fnStr.includes('document.title')) return 'Verify Mobile Number'; + if (fnStr.includes('innerText')) return 'Enter OTP sent to your registered mobile number.'; + return ''; + }), + }; + + const mockFactory = { + connect: vi.fn().mockResolvedValue(mockPage), + close: vi.fn().mockResolvedValue(undefined), + }; + + const manager = new BrowserManager({ factory: mockFactory as any }); + const session = await manager.startSession(); + + const state = await manager.navigate(session, 'https://temple.gov.in/auth/verify-otp'); + expect(state.isSensitive).toBe(true); + expect(state.sensitiveReason).toContain('OTP'); + expect(session.isPaused).toBe(true); + }); + }); + + describe('4. error_handler.ts', () => { + it('exports all 11 exact error code constants individually and in ErrorCode map', () => { + expect(NAVIGATION_FAILED).toBe('NAVIGATION_FAILED'); + expect(TIMEOUT).toBe('TIMEOUT'); + expect(ELEMENT_NOT_FOUND).toBe('ELEMENT_NOT_FOUND'); + expect(PAGE_CHANGED).toBe('PAGE_CHANGED'); + expect(WEBSITE_UNAVAILABLE).toBe('WEBSITE_UNAVAILABLE'); + expect(RATE_LIMITED).toBe('RATE_LIMITED'); + expect(LOGIN_REQUIRED).toBe('LOGIN_REQUIRED'); + expect(CAPTCHA_DETECTED).toBe('CAPTCHA_DETECTED'); + expect(NO_AVAILABILITY).toBe('NO_AVAILABILITY'); + expect(INVALID_INPUT).toBe('INVALID_INPUT'); + expect(UNKNOWN_ERROR).toBe('UNKNOWN_ERROR'); + + expect(ErrorCode.NAVIGATION_FAILED).toBe(NAVIGATION_FAILED); + expect(ErrorCode.TIMEOUT).toBe(TIMEOUT); + expect(ErrorCode.ELEMENT_NOT_FOUND).toBe(ELEMENT_NOT_FOUND); + expect(ErrorCode.PAGE_CHANGED).toBe(PAGE_CHANGED); + expect(ErrorCode.WEBSITE_UNAVAILABLE).toBe(WEBSITE_UNAVAILABLE); + expect(ErrorCode.RATE_LIMITED).toBe(RATE_LIMITED); + expect(ErrorCode.LOGIN_REQUIRED).toBe(LOGIN_REQUIRED); + expect(ErrorCode.CAPTCHA_DETECTED).toBe(CAPTCHA_DETECTED); + expect(ErrorCode.NO_AVAILABILITY).toBe(NO_AVAILABILITY); + expect(ErrorCode.INVALID_INPUT).toBe(INVALID_INPUT); + expect(ErrorCode.UNKNOWN_ERROR).toBe(UNKNOWN_ERROR); + }); + + it('classifies all 11 standard error conditions using ErrorHandler.classify', () => { + // 1. NAVIGATION_FAILED + expect(ErrorHandler.classify({ message: 'net::ERR_NAME_NOT_RESOLVED' })).toBe(NAVIGATION_FAILED); + expect(ErrorHandler.classify(new Error('Navigation failed: cannot navigate to host'))).toBe(NAVIGATION_FAILED); + + // 2. TIMEOUT + expect(ErrorHandler.classify({ message: 'ETIMEDOUT: connection timed out' })).toBe(TIMEOUT); + expect(ErrorHandler.classify(new Error('Operation timeout after 15000ms'))).toBe(TIMEOUT); + + // 3. ELEMENT_NOT_FOUND + expect(ErrorHandler.classify({ message: 'Selector .darshan-slot element not found' })).toBe(ELEMENT_NOT_FOUND); + + // 4. PAGE_CHANGED + expect(ErrorHandler.classify({ message: 'Unexpected page layout: page changed after update' })).toBe(PAGE_CHANGED); + expect(ErrorHandler.classify({ message: 'Stale element reference in DOM' })).toBe(PAGE_CHANGED); + + // 5. WEBSITE_UNAVAILABLE + expect(ErrorHandler.classify({ status: 503, message: 'Service Unavailable' })).toBe(WEBSITE_UNAVAILABLE); + expect(ErrorHandler.classify({ message: 'ECONNREFUSED 127.0.0.1:443' })).toBe(WEBSITE_UNAVAILABLE); + + // 6. RATE_LIMITED + expect(ErrorHandler.classify({ statusCode: 429, message: 'Too Many Requests' })).toBe(RATE_LIMITED); + expect(ErrorHandler.classify({ message: 'Rate limit exceeded, please slow down' })).toBe(RATE_LIMITED); + + // 7. LOGIN_REQUIRED + expect(ErrorHandler.classify({ status: 401, message: 'Authentication required, please sign in' })).toBe(LOGIN_REQUIRED); + expect(ErrorHandler.classify({ message: 'Session expired: login required' })).toBe(LOGIN_REQUIRED); + + // 8. CAPTCHA_DETECTED + expect(ErrorHandler.classify({ message: 'Cloudflare captcha challenge encountered' })).toBe(CAPTCHA_DETECTED); + expect(ErrorHandler.classify({ message: 'hCaptcha token required' })).toBe(CAPTCHA_DETECTED); + + // 9. NO_AVAILABILITY + expect(ErrorHandler.classify({ message: 'No availability found for selected date' })).toBe(NO_AVAILABILITY); + expect(ErrorHandler.classify({ message: 'All slots booked, quota exhausted' })).toBe(NO_AVAILABILITY); + + // 10. INVALID_INPUT + expect(ErrorHandler.classify({ status: 400, message: 'Invalid input parameter: pilgrim count cannot be 0' })).toBe(INVALID_INPUT); + + // 11. UNKNOWN_ERROR + expect(ErrorHandler.classify({ message: 'Uncaught internal reference problem' })).toBe(UNKNOWN_ERROR); + expect(ErrorHandler.classify(null)).toBe(UNKNOWN_ERROR); + expect(ErrorHandler.classify(undefined)).toBe(UNKNOWN_ERROR); + }); + + it('works as an instance method as well', () => { + const handler = new ErrorHandler(); + expect(handler.classify(new Error('Connection timed out'))).toBe(TIMEOUT); + }); + }); + + describe('5. recovery.ts', () => { + it('retries transient failures up to default 3 attempts and succeeds', async () => { + let attemptsRun = 0; + const transientFn = vi.fn().mockImplementation(async (attempt: number) => { + attemptsRun = attempt; + if (attempt < 3) { + throw new Error('Temporary gateway hiccup'); + } + return { darshanDate: '2026-10-15', available: true }; + }); + + const res: RetryResult<{ darshanDate: string; available: boolean }> = await RecoveryManager.retry( + transientFn, + { backoffMs: 1 }, + ); + + expect(res.success).toBe(true); + expect(res.attempts).toBe(3); + expect(res.result).toEqual({ darshanDate: '2026-10-15', available: true }); + expect(res.requiresApproval).toBe(false); + expect(transientFn).toHaveBeenCalledTimes(3); + }); + + it('stops after maximum 3 attempts on persistent failure', async () => { + const failingFn = vi.fn().mockRejectedValue(new Error('Portal is completely offline')); + + const res = await RecoveryManager.retry(failingFn, { backoffMs: 1 }); + + expect(res.success).toBe(false); + expect(res.attempts).toBe(3); + expect(res.requiresApproval).toBe(false); + expect(res.error).toBeDefined(); + expect(failingFn).toHaveBeenCalledTimes(3); + }); + + it('NEVER executes or retries dangerous or protected actions: payment, OTP, final submit, checkout, confirmation', async () => { + const protectedActionKeywords = [ + 'make_payment', + 'pay_gateway', + 'checkout_cart', + 'verify_otp', + 'enter_otp_pin', + 'final_submit_form', + 'final submit', + 'confirm_booking', + 'order_confirmation', + ]; + + for (const action of protectedActionKeywords) { + const protectedFn = vi.fn().mockResolvedValue('should not run'); + const res = await RecoveryManager.retry(protectedFn, { actionName: action }); + + expect(protectedFn).not.toHaveBeenCalled(); + expect(res.success).toBe(false); + expect(res.attempts).toBe(0); + expect(res.requiresApproval).toBe(true); + expect(res.approvalReason).toContain('approval is required'); + expect((res.error as any)?.code).toBe('APPROVAL_REQUIRED'); + + // Verify caller can convert into approval_required upstream + const upstreamResult = createApprovalRequiredResult('temple', action, res.approvalReason!); + expect(upstreamResult.status).toBe('approval_required'); + expect(upstreamResult.metadata.approvalReason).toBe(res.approvalReason); + } + }); + + it('respects isProtected policy flag even with arbitrary action name', async () => { + const protectedFn = vi.fn().mockResolvedValue('blocked'); + const res = await RecoveryManager.retry(protectedFn, { + actionName: 'harmless_name', + isProtected: true, + }); + + expect(protectedFn).not.toHaveBeenCalled(); + expect(res.requiresApproval).toBe(true); + }); + + it('halts immediately if execution encounters a protected checkpoint or OTP wall during run', async () => { + let callCount = 0; + const fnWithCheckpoint = vi.fn().mockImplementation(async () => { + callCount++; + throw new Error('Redirected to OTP verification page'); + }); + + const res = await RecoveryManager.retry(fnWithCheckpoint, { + actionName: 'navigate_booking', + backoffMs: 1, + }); + + // Crucial: must NOT retry after discovering OTP checkpoint! + expect(callCount).toBe(1); + expect(res.success).toBe(false); + expect(res.requiresApproval).toBe(true); + expect(res.approvalReason).toContain('OTP verification'); + }); + + it('halts immediately if fn returns a result indicating approval_required', async () => { + let callCount = 0; + const fnReturningApproval = vi.fn().mockImplementation(async () => { + callCount++; + return { status: 'approval_required', reason: 'HITL payment step reached' }; + }); + + const res = await RecoveryManager.retry(fnReturningApproval, { + actionName: 'book_darshan', + backoffMs: 1, + }); + + expect(callCount).toBe(1); + expect(res.success).toBe(false); + expect(res.requiresApproval).toBe(true); + expect(res.approvalReason).toBe('HITL payment step reached'); + }); + + it('supports instance method usage', async () => { + const manager = new RecoveryManager(); + const res = await manager.retry(async () => 'hello', { backoffMs: 1 }); + expect(res.success).toBe(true); + expect(res.result).toBe('hello'); + }); + }); +}); diff --git a/src/adapters/core/error_handler.ts b/src/adapters/core/error_handler.ts new file mode 100644 index 000000000..64464266d --- /dev/null +++ b/src/adapters/core/error_handler.ts @@ -0,0 +1,142 @@ +/** + * Standard Error Codes for PC2A Core. + * Defined and exported individually and grouped in ErrorCode. + */ +export const NAVIGATION_FAILED = 'NAVIGATION_FAILED' as const; +export const TIMEOUT = 'TIMEOUT' as const; +export const ELEMENT_NOT_FOUND = 'ELEMENT_NOT_FOUND' as const; +export const PAGE_CHANGED = 'PAGE_CHANGED' as const; +export const WEBSITE_UNAVAILABLE = 'WEBSITE_UNAVAILABLE' as const; +export const RATE_LIMITED = 'RATE_LIMITED' as const; +export const LOGIN_REQUIRED = 'LOGIN_REQUIRED' as const; +export const CAPTCHA_DETECTED = 'CAPTCHA_DETECTED' as const; +export const NO_AVAILABILITY = 'NO_AVAILABILITY' as const; +export const INVALID_INPUT = 'INVALID_INPUT' as const; +export const UNKNOWN_ERROR = 'UNKNOWN_ERROR' as const; + +export const ErrorCode = { + NAVIGATION_FAILED, + TIMEOUT, + ELEMENT_NOT_FOUND, + PAGE_CHANGED, + WEBSITE_UNAVAILABLE, + RATE_LIMITED, + LOGIN_REQUIRED, + CAPTCHA_DETECTED, + NO_AVAILABILITY, + INVALID_INPUT, + UNKNOWN_ERROR, +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** + * ErrorHandler classifies raw errors from Webcmd, network layers, or portals into standard ErrorCodes. + */ +export class ErrorHandler { + /** + * Instance classifier method. + */ + classify(rawError: unknown): ErrorCode { + return ErrorHandler.classify(rawError); + } + + /** + * Classifies a raw error into one of the 11 exact ErrorCode values. + */ + static classify(rawError: unknown): ErrorCode { + if (!rawError) { + return ErrorCode.UNKNOWN_ERROR; + } + + const err = (typeof rawError === 'object' && rawError !== null) + ? (rawError as Record) + : {}; + const message = typeof err.message === 'string' ? err.message : String(rawError); + const code = typeof err.code === 'string' ? err.code : ''; + const name = typeof err.name === 'string' ? err.name : ''; + const status = typeof err.status === 'number' || typeof err.status === 'string' ? String(err.status) : ''; + const statusCode = typeof err.statusCode === 'number' || typeof err.statusCode === 'string' ? String(err.statusCode) : ''; + const combined = `${name} ${code} ${status} ${statusCode} ${message}`.toLowerCase(); + + // 1. CAPTCHA_DETECTED + if (/captcha|recaptcha|hcaptcha|turnstile|bot detection|cf-chl/i.test(combined)) { + return ErrorCode.CAPTCHA_DETECTED; + } + + // 2. TIMEOUT + if (/time(d)?\s*out|etimedout|timeout/i.test(combined)) { + return ErrorCode.TIMEOUT; + } + + // 3. RATE_LIMITED + if (/rate\s*limit|429|too many requests|throttl/i.test(combined)) { + return ErrorCode.RATE_LIMITED; + } + + // 4. WEBSITE_UNAVAILABLE + if ( + /503|502|econnrefused|service unavailable|bad gateway|site is down|under maintenance/i.test( + combined, + ) + ) { + return ErrorCode.WEBSITE_UNAVAILABLE; + } + + // 5. NAVIGATION_FAILED + if ( + /navigation failed|net::err|err_name_not_resolved|err_connection|dns probe|cannot navigate/i.test( + combined, + ) + ) { + return ErrorCode.NAVIGATION_FAILED; + } + + // 6. LOGIN_REQUIRED + if ( + /login required|please sign in|unauthorized|401|auth(?:entication)? required|session expired/i.test( + combined, + ) + ) { + return ErrorCode.LOGIN_REQUIRED; + } + + // 7. ELEMENT_NOT_FOUND + if ( + /element not found|no such element|selector .* not found|target element missing|could not find element/i.test( + combined, + ) + ) { + return ErrorCode.ELEMENT_NOT_FOUND; + } + + // 8. PAGE_CHANGED + if ( + /page changed|stale element|unexpected page layout|dom layout altered|schema mismatch/i.test( + combined, + ) + ) { + return ErrorCode.PAGE_CHANGED; + } + + // 9. NO_AVAILABILITY + if ( + /no availability|no slots available|all slots booked|sold out|quota exhausted|seats? unavailable|sold_out/i.test( + combined, + ) + ) { + return ErrorCode.NO_AVAILABILITY; + } + + // 10. INVALID_INPUT + if ( + /invalid input|validation error|bad request|400|invalid date|missing parameter/i.test( + combined, + ) + ) { + return ErrorCode.INVALID_INPUT; + } + + return ErrorCode.UNKNOWN_ERROR; + } +} diff --git a/src/adapters/core/hitl-guard.ts b/src/adapters/core/hitl-guard.ts new file mode 100644 index 000000000..c2a208534 --- /dev/null +++ b/src/adapters/core/hitl-guard.ts @@ -0,0 +1,156 @@ +import { + IBrowserSession, + IHitlDetector, + HitlCheckResult, + HitlReason, +} from './types.js'; + +interface MarkerRule { + reason: HitlReason; + urlPatterns: RegExp[]; + contentPatterns: RegExp[]; + description: string; +} + +/** + * Default heuristic rules identifying pages that require Human-In-The-Loop approval. + */ +const DEFAULT_HITL_RULES: MarkerRule[] = [ + { + reason: 'payment_page', + urlPatterns: [ + /\/pay(\/|$|\?)/i, + /\/checkout(\/|$|\?)/i, + /\/payment(-gateway)?/i, + /billdesk\.com/i, + /razorpay\.com/i, + /paytm\.com/i, + /ccavenue\.com/i, + /sbi(e)?pay/i, + ], + contentPatterns: [ + /\b(enter card number|cvv|expiry date|valid thru)\b/i, + /\b(upi id|scan to pay|upi qr)\b/i, + /\b(net banking|select your bank)\b/i, + /\b(payment gateway|order summary|amount payable)\b/i, + ], + description: 'Payment or checkout gateway detected', + }, + { + reason: 'otp_verification', + urlPatterns: [ + /\/otp(\/|$|\?)/i, + /\/verify(-otp)?(\/|$|\?)/i, + /\/two-factor(\/|$|\?)/i, + /\/2fa(\/|$|\?)/i, + ], + contentPatterns: [ + /\b(enter otp|enter one-time password|otp sent to)\b/i, + /\b(resend otp|verify otp|otp verification)\b/i, + /autocomplete=["']one-time-code["']/i, + ], + description: 'OTP / Two-Factor authentication screen detected', + }, + { + reason: 'captcha_challenge', + urlPatterns: [ + /\/captcha(\/|$|\?)/i, + /\/challenge(\/|$|\?)/i, + /recaptcha/i, + /hcaptcha/i, + ], + contentPatterns: [ + /\b(verify you are human|select all squares with|i'm not a robot)\b/i, + /\b(enter the characters shown|enter captcha)\b/i, + /class=["'][^"']*(g-recaptcha|h-captcha)[^"']*["']/i, + ], + description: 'Captcha challenge detected', + }, + { + reason: 'final_submit', + urlPatterns: [ + /\/confirm(-booking)?(\/|$|\?)/i, + /\/final-submit(\/|$|\?)/i, + /\/review-and-pay(\/|$|\?)/i, + ], + contentPatterns: [ + /\b(confirm & pay|proceed to pay|pay now|complete booking)\b/i, + /\b(submit application|authorize transaction)\b/i, + ], + description: 'Final submission / irreversible commitment point detected', + }, +]; + +export class HitlGuard implements IHitlDetector { + private rules: MarkerRule[]; + + constructor(customRules?: MarkerRule[]) { + this.rules = customRules || DEFAULT_HITL_RULES; + } + + /** + * Evaluates the current session state (URL and DOM content) against HITL criteria. + */ + async check(session: IBrowserSession): Promise { + if (session.isClosed) { + return { requiresApproval: false }; + } + + const currentUrl = await session.getCurrentUrl(); + const content = await session.getPageContent(); + + for (const rule of this.rules) { + // 1. Check URL patterns + for (const pattern of rule.urlPatterns) { + if (pattern.test(currentUrl)) { + return { + requiresApproval: true, + reason: rule.reason, + details: `${rule.description} (matched URL pattern: ${pattern})`, + }; + } + } + + // 2. Check DOM content patterns + for (const pattern of rule.contentPatterns) { + if (pattern.test(content)) { + return { + requiresApproval: true, + reason: rule.reason, + details: `${rule.description} (matched content pattern: ${pattern})`, + }; + } + } + } + + return { requiresApproval: false }; + } + + /** + * Cross-cutting wrapper that checks the HITL guard before and after an operation. + * If a trigger is detected at any point, the session is paused and approval is flagged. + */ + async runWithGuard( + session: IBrowserSession, + action: () => Promise, + ): Promise<{ requiresApproval: true; hitl: HitlCheckResult } | { requiresApproval: false; result: T }> { + // Pre-check + const preCheck = await this.check(session); + if (preCheck.requiresApproval) { + await session.pause(); + return { requiresApproval: true, hitl: preCheck }; + } + + // Execute wrapped action + const result = await action(); + + // Post-check + const postCheck = await this.check(session); + if (postCheck.requiresApproval) { + await session.pause(); + return { requiresApproval: true, hitl: postCheck }; + } + + return { requiresApproval: false, result }; + } +} diff --git a/src/adapters/core/recovery.ts b/src/adapters/core/recovery.ts new file mode 100644 index 000000000..e0e5a4eb0 --- /dev/null +++ b/src/adapters/core/recovery.ts @@ -0,0 +1,168 @@ +/** + * Policy configuring retry behavior. + */ +export interface RetryPolicy { + /** Maximum number of attempts. Default is 3. */ + maxAttempts?: number; + /** Milliseconds delay between retries. */ + backoffMs?: number; + /** Name or identifier of the action being performed. */ + actionName?: string; + /** Explicit flag indicating whether the action is protected. */ + isProtected?: boolean; +} + +/** + * Result of executing an operation through RecoveryManager. + */ +export interface RetryResult { + success: boolean; + attempts: number; + result?: T; + /** True when execution hit a protected boundary and must halt for approval. */ + requiresApproval: boolean; + approvalReason?: string; + error?: unknown; +} + +/** + * RecoveryManager implements resilient retry logic with strict safety boundaries. + * Enforces rule: Never retry dangerous or protected actions (payment, OTP, final submit, checkout, confirmation). + */ +export class RecoveryManager { + private static readonly PROTECTED_KEYWORDS = [ + 'payment', + 'pay', + 'checkout', + 'otp', + 'two-factor', + '2fa', + 'final-submit', + 'final_submit', + 'final submit', + 'confirm-booking', + 'confirmation', + 'confirm', + 'complete booking', + 'authorize', + ]; + + /** + * Evaluates if a given string references a dangerous or protected action/state. + */ + static isProtected(text: string): boolean { + const lower = text.toLowerCase(); + return this.PROTECTED_KEYWORDS.some((kw) => lower.includes(kw)); + } + + /** + * Instance method forwarding to static retry. + */ + async retry( + fn: (attempt: number) => Promise, + policy?: RetryPolicy, + ): Promise> { + return RecoveryManager.retry(fn, policy); + } + + /** + * Executes an operation with retry, respecting safety boundaries. + * + * @param fn Function to execute, receiving current attempt count (1-indexed). + * @param policy Optional retry configuration. Defaults to 3 max attempts. + */ + static async retry( + fn: (attempt: number) => Promise, + policy?: RetryPolicy, + ): Promise> { + const maxAttempts = policy?.maxAttempts ?? 3; + const actionName = policy?.actionName || fn.name || 'operation'; + + // 1. Never retry dangerous or protected actions + if (policy?.isProtected || this.isProtected(actionName)) { + return { + success: false, + attempts: 0, + requiresApproval: true, + approvalReason: `Protected action '${actionName}' cannot be automatically executed or retried. Human-In-The-Loop approval is required.`, + error: { + code: 'APPROVAL_REQUIRED', + message: `Protected action '${actionName}' is protected and requires approval.`, + }, + }; + } + + let attempt = 0; + let lastError: unknown = null; + + while (attempt < maxAttempts) { + attempt++; + try { + const result = await fn(attempt); + + // Check if result object itself signals an approval_required state + if ( + typeof result === 'object' && + result !== null && + (('requiresApproval' in result && (result as Record).requiresApproval === true) || + ('status' in result && (result as Record).status === 'approval_required')) + ) { + const resObj = result as Record; + return { + success: false, + attempts: attempt, + result, + requiresApproval: true, + approvalReason: + (typeof resObj.approvalReason === 'string' ? resObj.approvalReason : undefined) || + (typeof resObj.reason === 'string' ? resObj.reason : undefined) || + `Action '${actionName}' reached protected state requiring approval.`, + }; + } + + return { + success: true, + attempts: attempt, + result, + requiresApproval: false, + }; + } catch (err) { + lastError = err; + const errMessage = err instanceof Error ? err.message : String(err); + const errObj = typeof err === 'object' && err !== null ? (err as Record) : {}; + + // 2. Check if the error itself signals that a protected page/wall was reached + const isProtectedErr = + this.isProtected(errMessage) || + errObj.requiresApproval === true || + errObj.status === 'approval_required' || + errObj.code === 'APPROVAL_REQUIRED'; + + if (isProtectedErr) { + return { + success: false, + attempts: attempt, + requiresApproval: true, + approvalReason: `Protected checkpoint encountered during '${actionName}': ${errMessage}`, + error: err, + }; + } + + // 3. Backoff before next attempt if attempts remain + if (attempt < maxAttempts) { + const delay = policy?.backoffMs !== undefined ? policy.backoffMs * attempt : 10 * attempt; + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + } + + return { + success: false, + attempts: attempt, + requiresApproval: false, + error: lastError, + }; + } +} diff --git a/src/adapters/core/result_schema.ts b/src/adapters/core/result_schema.ts new file mode 100644 index 000000000..70ec9c6ae --- /dev/null +++ b/src/adapters/core/result_schema.ts @@ -0,0 +1,108 @@ +/** + * StandardResult Contract for PilgrimOS Adapters (PC2A Core). + * + * All adapters return this exact shape to guarantee uniform responses to PC1. + */ + +export type StandardResultStatus = + | 'completed' + | 'searching' + | 'partial' + | 'failed' + | 'retrying' + | 'blocked' + | 'approval_required'; + +export interface StandardResultMetadata { + source: string; + timestamp: string; + [key: string]: unknown; +} + +export interface StandardResult> { + success: boolean; + status: StandardResultStatus; + adapter: string; + action: string; + data: T | null; + metadata: StandardResultMetadata; + error: Record | null; +} + +/** + * Factory helpers to generate StandardResult instances with strict typing. + */ +export function createSuccessResult( + adapter: string, + action: string, + data: T, + source = 'webcmd', + extraMetadata?: Record, +): StandardResult { + return { + success: true, + status: 'completed', + adapter, + action, + data, + metadata: { + source, + timestamp: new Date().toISOString(), + ...extraMetadata, + }, + error: null, + }; +} + +export function createApprovalRequiredResult( + adapter: string, + action: string, + reason: string, + source = 'webcmd', + extraMetadata?: Record, +): StandardResult { + return { + success: false, + status: 'approval_required', + adapter, + action, + data: null, + metadata: { + source, + timestamp: new Date().toISOString(), + approvalReason: reason, + ...extraMetadata, + }, + error: { + code: 'APPROVAL_REQUIRED', + message: `Action '${action}' reached protected state: ${reason}`, + }, + }; +} + +export function createFailureResult( + adapter: string, + action: string, + errorCode: string, + errorMessage: string, + source = 'webcmd', + details?: unknown, + status: StandardResultStatus = 'failed', +): StandardResult { + return { + success: false, + status, + adapter, + action, + data: null, + metadata: { + source, + timestamp: new Date().toISOString(), + }, + error: { + code: errorCode, + message: errorMessage, + details: details ?? null, + }, + }; +} diff --git a/src/adapters/core/types.ts b/src/adapters/core/types.ts new file mode 100644 index 000000000..23a26f3f6 --- /dev/null +++ b/src/adapters/core/types.ts @@ -0,0 +1,114 @@ +/** + * Core interface definitions for the PRAVAAH Adapters middle layer. + * + * Sits between Backend (PC1) and Core Browser Automation Engine (PC2A). + */ + +export type AdapterName = 'temple' | 'travel' | 'hotel'; + +export type ExecutionStatus = 'completed' | 'failed' | 'approval_required'; + +export interface StandardResultMetadata { + timestamp: string; + durationMs?: number; + portal?: string; + sessionId?: string; + [key: string]: unknown; +} + +export interface StandardResultError { + code: string; + message: string; + retryable: boolean; + details?: unknown; +} + +/** + * Mandatory contract returned by every adapter flow. + * Ensures clean swappability and a uniform response shape for Backend (PC1). + */ +export interface StandardResult { + success: boolean; + status: ExecutionStatus; + adapter: AdapterName; + action: string; + data: T | null; + metadata: StandardResultMetadata; + error?: StandardResultError; +} + +/** + * Options for configuring browser session startup. + */ +export interface SessionOptions { + headless?: boolean; + proxy?: string; + timeoutMs?: number; + [key: string]: unknown; +} + +/** + * Abstract interface for interacting with a browser session. + * Real PC2A core engine and local mocks both implement this interface. + */ +export interface IBrowserSession { + readonly id: string; + readonly isClosed: boolean; + readonly isPaused: boolean; + + navigate(url: string): Promise; + getCurrentUrl(): Promise; + getPageContent(): Promise; + evaluate(fn: () => R): Promise; + pause(): Promise; + resume(): Promise; + close(): Promise; +} + +/** + * Abstract interface for managing browser sessions. + */ +export interface IBrowserManager { + startSession(options?: SessionOptions): Promise; + closeSession(sessionId: string): Promise; +} + +/** + * Classified error representation for resilient retry and failure reporting. + */ +export interface ClassifiedError { + code: string; + message: string; + retryable: boolean; + originalError?: unknown; +} + +/** + * Abstract error classification interface. + */ +export interface IErrorHandler { + classify(error: unknown): ClassifiedError; +} + +/** + * Human-In-The-Loop (HITL) detection result. + */ +export type HitlReason = + | 'payment_page' + | 'otp_verification' + | 'captcha_challenge' + | 'final_submit' + | 'manual_intervention'; + +export interface HitlCheckResult { + requiresApproval: boolean; + reason?: HitlReason; + details?: string; +} + +/** + * Interface for cross-cutting HITL detectors. + */ +export interface IHitlDetector { + check(session: IBrowserSession): Promise; +} diff --git a/src/adapters/hotel/hotel-adapter.ts b/src/adapters/hotel/hotel-adapter.ts new file mode 100644 index 000000000..7e0d764f6 --- /dev/null +++ b/src/adapters/hotel/hotel-adapter.ts @@ -0,0 +1,140 @@ +import { + IBrowserManager, + IBrowserSession, + IErrorHandler, + StandardResult, +} from '../core/types.js'; +import { HitlGuard } from '../core/hitl-guard.js'; + +export interface HotelSearchInput { + destination: string; + checkInDate: string; + checkOutDate: string; + guests: number; + rooms?: number; + portalUrl?: string; +} + +export interface HotelOption { + hotelId: string; + name: string; + rating: number; + distanceFromTempleKm: number; + pricePerNight: number; + roomType: string; + isAvailable: boolean; +} + +export interface HotelAvailabilityData { + destination: string; + checkInDate: string; + checkOutDate: string; + hotels: HotelOption[]; +} + +export class HotelAdapter { + constructor( + private browserManager: IBrowserManager, + private errorHandler: IErrorHandler, + private hitlGuard: HitlGuard = new HitlGuard(), + ) {} + + async checkAvailability( + input: HotelSearchInput, + ): Promise> { + const startTime = Date.now(); + let session: IBrowserSession | null = null; + const portalUrl = input.portalUrl || 'https://booking.com/searchresults.html'; + + try { + session = await this.browserManager.startSession(); + + const navGuard = await this.hitlGuard.runWithGuard(session, async () => { + await session!.navigate(portalUrl); + }); + + if (navGuard.requiresApproval) { + return { + success: false, + status: 'approval_required', + adapter: 'hotel', + action: 'check_availability', + data: null, + metadata: { + timestamp: new Date().toISOString(), + durationMs: Date.now() - startTime, + portal: portalUrl, + sessionId: session.id, + hitlReason: navGuard.hitl.reason, + hitlDetails: navGuard.hitl.details, + }, + }; + } + + const sampleHotels: HotelOption[] = [ + { + hotelId: 'h-101', + name: 'Temple View Residency', + rating: 4.6, + distanceFromTempleKm: 0.4, + pricePerNight: 2400, + roomType: 'Deluxe AC Room', + isAvailable: true, + }, + { + hotelId: 'h-102', + name: 'Pilgrim Ashray Bhavan', + rating: 4.2, + distanceFromTempleKm: 1.1, + pricePerNight: 1200, + roomType: 'Standard Non-AC Room', + isAvailable: true, + }, + ]; + + await this.browserManager.closeSession(session.id); + session = null; + + return { + success: true, + status: 'completed', + adapter: 'hotel', + action: 'check_availability', + data: { + destination: input.destination, + checkInDate: input.checkInDate, + checkOutDate: input.checkOutDate, + hotels: sampleHotels, + }, + metadata: { + timestamp: new Date().toISOString(), + durationMs: Date.now() - startTime, + portal: portalUrl, + }, + }; + } catch (err) { + if (session) { + await this.browserManager.closeSession(session.id).catch(() => {}); + } + const classified = this.errorHandler.classify(err); + return { + success: false, + status: 'failed', + adapter: 'hotel', + action: 'check_availability', + data: null, + metadata: { + timestamp: new Date().toISOString(), + durationMs: Date.now() - startTime, + portal: portalUrl, + }, + error: { + code: classified.code, + message: classified.message, + retryable: classified.retryable, + details: err, + }, + }; + } + } +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 000000000..35167fe65 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,13 @@ +// Public barrel export for PRAVAAH Adapters Layer + +export * from './core/types.js'; +export * from './core/core-mock.js'; +export * from './core/hitl-guard.js'; + +export * from './temple/types.js'; +export * from './temple/availability.js'; +export * from './temple/crowd-estimator.js'; +export * from './temple/temple-adapter.js'; + +export * from './travel/travel-adapter.js'; +export * from './hotel/hotel-adapter.js'; diff --git a/src/adapters/temple/availability.ts b/src/adapters/temple/availability.ts new file mode 100644 index 000000000..910454c6e --- /dev/null +++ b/src/adapters/temple/availability.ts @@ -0,0 +1,105 @@ +import { IBrowserSession } from '../core/types.js'; +import { TempleInput, SlotInfo } from './types.js'; + +export class TempleAvailabilityExtractor { + /** + * Extracts slot availability from the active portal session. + */ + async extractSlots( + session: IBrowserSession, + input: TempleInput, + ): Promise { + const pageContent = await session.getPageContent(); + + // 1. Check if portal explicitly indicates no availability or closed bookings + if ( + /no slots available|all slots booked|booking closed|quota exhausted/i.test( + pageContent, + ) + ) { + return []; + } + + // 2. Check for embedded JSON payload (e.g. state hydrated in window.__INITIAL_STATE__ or script tag) + const jsonMatch = pageContent.match(/