Files
actionbook__actionbook/packages/dify-plugin/utils/connection_pool.py
sen 93caf967e0 [New Package] Dify Plugin - Browser Automation Manuals (#86)
* [packages/dify-plugin]feat: add Dify plugin for browser automation

- Implement search_actions and get_action_by_area_id tools
- Support API key authentication via Dify plugin SDK
- Add comprehensive tests (34 tests, 99% coverage)
- Add documentation (README, PRIVACY)
- Phase 1: Query action manuals via API

Tools:
- search_actions: Find website elements by keyword/domain filter
- get_action_by_area_id: Get complete selector details by area ID

Testing:
- Unit tests: provider, search_actions, get_action_by_area_id
- Coverage: 99% (412 statements, 3 missed)
- Linting: ruff with zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* [packages/dify-plugin]fix: align SDK interface, fix API issues and improve reliability

- Fix API endpoint and parameter alignment with Actionbook API
- Align with Dify SDK interface (provider structure, tool signatures)
- Restructure provider: replace provider.yaml with provider/ package
- Add test infrastructure: unit tests, integration tests, E2E checklist
- Improve error handling and remove legacy provider.yaml
- Resolve empty response issue in Dify Cloud
- Pin dify-plugin>=0.5.1,<0.6.0 to prevent breaking changes
- Add *.difypkg, .coverage, .alma-snapshots to root .gitignore

* [packages/dify-plugin]fix: address code review issues in source code

- C4: Add insecure CDP URL warning for non-localhost ws:// connections
- C5: Replace print(stderr) with proper logger in search/get tools
- C6: Re-raise exception after logging in HyperbrowserSession.stop()
- W8: Make API_BASE_URL configurable via ACTIONBOOK_API_URL env var
- W-limit: Fix float limit validation with int() cast
- W-except: Narrow bare Exception to PlaywrightTimeout in operator
- W-use_proxy: Fix bool comparison for string/bool/whitespace inputs
- W7: Document _get_active_page tab selection behavior
- Fix type annotation in providers/__init__.py
- Update misleading comment in provider/actionbook.py
- Add cleanup warning in browser_create_session.yaml

* [packages/dify-plugin]fix: correct integration tests to match tool behavior

Tools yield error messages instead of raising exceptions.
Fix tests that incorrectly used pytest.raises(ValueError) to
instead assert on the returned message content.

* [packages/dify-plugin]test: add missing unit tests for improved coverage

- New test_browser_stop_session.py (6 tests)
- Add HTTP 403, SSL/connection error branch tests for search/get tools
- Add use_proxy edge cases and default provider tests
- Add malformed cdp_url test for browser operator
- Add insecure CDP URL warning tests
- Update operator tests for PlaywrightTimeout narrowing
- Add provider re-raise test

184 tests pass at 86% coverage.

* [packages/dify-plugin]test: add E2E browser tests with Hyperbrowser

- Add verify_hyperbrowser.py manual verification script
- Add 8 E2E tests: tool wrappers (create/stop/navigate/screenshot)
  and Playwright direct (multi-step, form fill, nav history, HTML)
- Add retry logic for intermittent gevent SSL errors
- Add tenacity to dev dependencies

Run: HYPERBROWSER_API_KEY=hb-xxx uv run pytest -m e2e -v --timeout=120 --no-cov

* [packages/dify-plugin]feat: add connection pool, playwright worker and API key resolver

- api_key.py: resolve provider key from params or env vars with placeholder filtering
- connection_pool.py: in-process pool of browser workers keyed by session_id,
  using subprocess-based Playwright workers to avoid asyncio conflicts
- playwright_worker.py: standalone Playwright worker process supporting
  CSS, XPath, and snapshot ref selectors (@eN / [ref=eN])

* [packages/dify-plugin]refactor: simplify cdp_client to URL validation only

Remove CDP connection logic (cdp_page, _get_active_page, CdpConnectionError)
since connections are now managed by the connection pool and playwright worker.
Retain only validate_cdp_url for input validation.

* [packages/dify-plugin]feat: add snapshot action, connection pool integration and improved error handling

browser_operator:
- Add snapshot action with accessibility tree (ported from actionbook-rs)
- Replace cdp_page with connection pool for persistent sessions
- Add SSRF protection for CDP URLs
- Improve click/type/fill error messages with recovery hints
- Remove screenshot action in favor of snapshot

browser_create_session:
- Integrate connection pool (pre-connect on session create)
- Add profile_id fallback retry logic
- Support API key from env vars via resolve_provider_api_key
- Validate provider name against SUPPORTED_PROVIDERS

browser_stop_session:
- Disconnect pooled connection before stopping provider session
- Improved error messages with troubleshooting hints

hyperbrowser provider:
- Add profile_id UUID normalization (uuid5 for non-UUID inputs)

actionbook provider:
- Add API health check in _validate_credentials

* [packages/dify-plugin]fix: improve search/get_action tools with validation and fallback hints

- Add area_id character validation regex to prevent injection
- Add snapshot fallback guidance in tool descriptions
- Reduce debug log verbosity (avoid logging raw parameters)

* [packages/dify-plugin]docs: update README workflow guide, manifest version and difyignore

- README: add snapshot-based recovery workflow and troubleshooting section
- manifest: set version to 0.1.0 (initial release)
- difyignore: add scripts/, dist/, .docs/, .DS_Store exclusions

* [packages/dify-plugin]chore: remove verify_hyperbrowser script

No longer needed — verification is covered by E2E tests.

* [packages/dify-plugin]test: update tests for connection pool, snapshot and improved tools

- Add test_connection_pool.py: pool connect/disconnect/execute lifecycle
- Add test_playwright_worker.py: worker process smoke test
- Add test_workflow_snapshot_recovery.py: snapshot-based selector recovery
- Update browser_operator tests for snapshot action and error handling
- Update browser_create/stop session tests for pool integration
- Update cdp_client tests (simplified to URL validation only)
- Update provider/search/get_action tests for new validation logic
- Replace screenshot references with snapshot in E2E tests

* [packages/dify-plugin]refactor: cache provider info in connection pool so stop_session auto-resolves credentials

browser_stop_session no longer requires provider/api_key params — they are
looked up from the pool via get_session_info(). api_key field in
browser_create_session changed to secret-input with form-level input.

* [packages/dify-plugin]docs: update README with browser tools and remove unused import

- Add documentation for browser_create_session, browser_stop_session,
  browser_operator tools
- Update features list and use cases to reflect browser automation
- Remove outdated Phase 2 roadmap (already implemented)
- Fix provider names (Hyperbrowser/Steel.dev, not Browserbase)
- Note that browser_stop_session auto-resolves provider/api_key
- Remove unused SUPPORTED_PROVIDERS import from browser_stop_session.py

* [packages/dify-plugin]fix: correct YAML descriptions and add zh_Hans translations

- Fix browser_create_session llm_description referencing non-existent
  tool names (browser_navigate → browser_operator)
- Remove Steel.dev mention from descriptions (not yet available)
- Update manifest description to reflect browser automation capabilities
- Add proper zh_Hans translations for search_actions, get_action_by_area_id,
  manifest and provider YAML files

* [packages/dify-plugin]fix: address CodeQL URL sanitization and logging findings

* [packages/dify-plugin]fix: remove dynamic data from error logs

* [packages/dify-plugin]feat: add tag-based release workflow and version sync

Add automated dify-plugin release pipeline:
- release.yml: tag detection (actionbook-dify-plugin-v{VERSION}) and
  release job that packages .difypkg via uv + creates GitHub Release
- sync-versions.js: sync package.json version to manifest.yaml (root +
  meta.version) and pyproject.toml with exact equality checks and
  fail-loud on missing fields

* [packages/dify-plugin]fix: handle stop/create session failure recovery

* [packages/dify-plugin]fix: resolve critical review findings — deadlock, exception safety, timeout handling

- Fix connection pool deadlock: move worker.stop() outside pool lock to
  prevent deadlock when _io_lock is held by another thread in call()/readline().
  All call sites (disconnect, disconnect_all, cleanup_stale, connect eviction,
  get_page health check) now pop under lock and stop outside lock.

- Fix BaseException swallowing: re-raise KeyboardInterrupt, SystemExit, and
  GeneratorExit in search_actions and get_action_by_area_id generators.
  Only non-Exception BaseException subclasses (e.g. gevent.Timeout) are caught.

- Fix timeout exception misclassification: add WorkerTimeoutError exception
  class. SubprocessWorker.call() now detects timeout errors in worker responses
  and raises WorkerTimeoutError instead of RuntimeError, so browser_operator
  handlers can show proper "element not found" hints instead of generic errors.

- Document SSRF DNS rebinding TOCTOU limitation in _is_ssrf_target with
  explanation of cloud browser architecture mitigation.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:49:21 +08:00

421 lines
14 KiB
Python

"""In-process pool of browser workers keyed by session_id.
Runtime note:
The Dify plugin process may run inside an active asyncio loop where
Playwright sync API is not usable. To avoid that, each session is executed
in a dedicated child Python process (utils/playwright_worker.py).
"""
import atexit
import json
import logging
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ConnectionNotFound(Exception):
"""Raised when no pooled connection exists for the given session_id."""
class ConnectionUnhealthy(Exception):
"""Raised when a pooled connection worker is no longer usable."""
class WorkerTimeoutError(Exception):
"""Raised when a worker operation times out (maps to PlaywrightTimeout)."""
class _KeyboardProxy:
def __init__(self, worker: "_SubprocessWorker") -> None:
self._worker = worker
def press(self, key: str) -> Any:
return self._worker.call("keyboard_press", {"key": key})
class _PooledPageProxy:
"""Playwright Page-like proxy backed by a subprocess worker."""
def __init__(self, worker: "_SubprocessWorker") -> None:
self._worker = worker
self.keyboard = _KeyboardProxy(worker)
@property
def url(self) -> str:
return str(self._worker.call("url", {}))
def title(self) -> str:
return str(self._worker.call("title", {}))
def goto(self, url: str, timeout: float | None = None, wait_until: str | None = None) -> Any:
return self._worker.call("goto", {
"url": url,
"timeout": timeout,
"wait_until": wait_until,
})
def wait_for_selector(self, selector: str, timeout: float | None = None) -> Any:
return self._worker.call("wait_for_selector", {
"selector": selector,
"timeout": timeout,
})
def click(self, selector: str) -> Any:
return self._worker.call("click", {"selector": selector})
def type(self, selector: str, text: str) -> Any:
return self._worker.call("type", {"selector": selector, "text": text})
def fill(self, selector: str, text: str) -> Any:
return self._worker.call("fill", {"selector": selector, "text": text})
def select_option(self, selector: str, value: str) -> Any:
return self._worker.call("select_option", {"selector": selector, "value": value})
def hover(self, selector: str) -> Any:
return self._worker.call("hover", {"selector": selector})
def inner_text(self, selector: str) -> str:
return str(self._worker.call("inner_text", {"selector": selector}))
def inner_html(self, selector: str) -> str:
return str(self._worker.call("inner_html", {"selector": selector}))
def content(self) -> str:
return str(self._worker.call("content", {}))
def wait_for_load_state(self, state: str, timeout: float | None = None) -> Any:
return self._worker.call("wait_for_load_state", {
"state": state,
"timeout": timeout,
})
def go_back(self) -> Any:
return self._worker.call("go_back", {})
def go_forward(self) -> Any:
return self._worker.call("go_forward", {})
def reload(self, wait_until: str | None = None) -> Any:
return self._worker.call("reload", {"wait_until": wait_until})
def evaluate(self, expression: str) -> Any:
return self._worker.call("evaluate", {"expression": expression})
class _SubprocessWorker:
"""Owns a single Playwright session in a child process."""
def __init__(self, session_id: str, ws_endpoint: str, timeout_ms: int = 30000) -> None:
self._session_id = session_id
self._timeout_ms = timeout_ms
self._next_id = 1
self._io_lock = threading.Lock()
self._proc = self._start_worker(ws_endpoint, timeout_ms)
self._handshake()
def _start_worker(self, ws_endpoint: str, timeout_ms: int) -> subprocess.Popen[str]:
worker_script = Path(__file__).with_name("playwright_worker.py")
cmd = [
sys.executable,
str(worker_script),
ws_endpoint,
str(timeout_ms),
]
return subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
def _handshake(self) -> None:
resp = self._read_response_line()
if not resp.get("ok"):
err = resp.get("error") or "unknown startup failure"
raise ConnectionUnhealthy(f"Worker startup failed for session '{self._session_id}': {err}")
def call(self, op: str, params: dict[str, Any]) -> Any:
if self._proc.poll() is not None:
raise ConnectionUnhealthy(
f"Browser worker exited for session '{self._session_id}' "
f"(code={self._proc.returncode})."
)
with self._io_lock:
req_id = self._next_id
self._next_id += 1
payload = {"id": req_id, "op": op, "params": params}
self._write_payload(payload)
resp = self._read_response_line()
if not resp.get("ok"):
error = resp.get("error") or "worker command failed"
# Detect Playwright timeout errors so callers can handle them
# distinctly from other failures (e.g. show "element not found"
# hints instead of generic errors).
if "TimeoutError" in error or "Timeout " in error:
raise WorkerTimeoutError(error)
raise RuntimeError(error)
return resp.get("result")
def stop(self) -> None:
if self._proc.poll() is not None:
return
try:
with self._io_lock:
self._write_payload({"id": 0, "op": "close", "params": {}})
# Do NOT read response — process may be mid-operation or
# already exiting. Blocking here risks deadlock.
except Exception:
pass
finally:
# Close file descriptors to avoid resource leaks.
for stream in (self._proc.stdin, self._proc.stdout):
try:
if stream is not None:
stream.close()
except Exception:
pass
try:
self._proc.terminate()
except Exception:
pass
try:
self._proc.wait(timeout=3)
except Exception:
try:
self._proc.kill()
except Exception:
pass
def _write_payload(self, payload: dict[str, Any]) -> None:
if self._proc.stdin is None:
raise ConnectionUnhealthy(f"Worker stdin unavailable for session '{self._session_id}'.")
self._proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
self._proc.stdin.flush()
def _read_response_line(self) -> dict[str, Any]:
if self._proc.stdout is None:
raise ConnectionUnhealthy(f"Worker stdout unavailable for session '{self._session_id}'.")
line = self._proc.stdout.readline()
if not line:
raise ConnectionUnhealthy(
f"Browser worker closed pipe for session '{self._session_id}'."
)
try:
return json.loads(line)
except json.JSONDecodeError as exc:
raise ConnectionUnhealthy(
f"Invalid worker response for session '{self._session_id}': {line[:200]}"
) from exc
@dataclass
class ManagedConnection:
"""A cached worker-backed CDP connection."""
worker: _SubprocessWorker
page: _PooledPageProxy
session_id: str
ws_endpoint: str
provider_name: str = "hyperbrowser"
api_key: str = ""
created_at: float = field(default_factory=time.time)
last_used_at: float = field(default_factory=time.time)
def touch(self) -> None:
self.last_used_at = time.time()
_DEFAULT_MAX_IDLE_SECONDS = 1800
_DEFAULT_MAX_POOL_SIZE = 20
_CLEANUP_INTERVAL_SECONDS = 300
class ConnectionPool:
"""Thread-safe pool of live browser workers keyed by session_id."""
def __init__(
self,
max_idle_seconds: float = _DEFAULT_MAX_IDLE_SECONDS,
max_size: int = _DEFAULT_MAX_POOL_SIZE,
) -> None:
self._connections: dict[str, ManagedConnection] = {}
self._lock = threading.Lock()
self._max_idle_seconds = max_idle_seconds
self._max_size = max_size
self._start_cleanup_thread()
def _start_cleanup_thread(self) -> None:
def _loop() -> None:
while True:
time.sleep(_CLEANUP_INTERVAL_SECONDS)
try:
self.cleanup_stale()
except Exception:
logger.exception("Pool: cleanup_stale error")
t = threading.Thread(target=_loop, daemon=True, name="pool-cleanup")
t.start()
def connect(
self,
session_id: str,
ws_endpoint: str,
timeout_ms: int = 30000,
provider_name: str = "hyperbrowser",
api_key: str = "",
) -> _PooledPageProxy:
# Spawn worker outside the lock (expensive I/O operation).
worker = _SubprocessWorker(
session_id=session_id,
ws_endpoint=ws_endpoint,
timeout_ms=timeout_ms,
)
page = _PooledPageProxy(worker)
conn = ManagedConnection(
worker=worker,
page=page,
session_id=session_id,
ws_endpoint=ws_endpoint,
provider_name=provider_name,
api_key=api_key,
)
evicted_conn: ManagedConnection | None = None
with self._lock:
# Evict any existing connection for this session_id (under lock).
evicted_conn = self._connections.pop(session_id, None)
if len(self._connections) >= self._max_size:
# Kill the just-spawned worker before raising.
worker.stop()
raise RuntimeError(
f"Connection pool is full ({self._max_size} sessions). "
"Stop an existing session before creating a new one."
)
self._connections[session_id] = conn
# Stop evicted worker outside lock to avoid deadlock.
if evicted_conn is not None:
_stop_worker_safely(evicted_conn.worker)
logger.info("Pool: evicted previous connection for session")
logger.info("Pool: connected session")
return page
_HEALTH_CHECK_INTERVAL = 30.0 # seconds idle before running a health check
def get_page(self, session_id: str) -> _PooledPageProxy:
with self._lock:
conn = self._connections.get(session_id)
if conn is None:
raise ConnectionNotFound(
f"No pooled connection for session '{session_id}'. "
"Was browser_create_session called first?"
)
# Only health-check if the connection has been idle for a while,
# or if the subprocess has exited.
idle_seconds = time.time() - conn.last_used_at
needs_check = (
idle_seconds > self._HEALTH_CHECK_INTERVAL
or conn.worker._proc.poll() is not None
)
if needs_check:
try:
conn.page.evaluate("1")
except Exception as exc:
# Remove from pool under lock, stop worker outside lock.
with self._lock:
removed = self._connections.pop(session_id, None)
if removed is not None:
_stop_worker_safely(removed.worker)
logger.info("Pool: disconnected session")
raise ConnectionUnhealthy(
f"Pooled connection for session '{session_id}' is dead: {exc}"
) from exc
conn.touch()
return conn.page
def disconnect(self, session_id: str) -> None:
with self._lock:
conn = self._connections.pop(session_id, None)
if conn is None:
logger.debug("Pool: disconnect called for unknown session")
return
# Stop worker outside lock to avoid deadlock with _io_lock.
_stop_worker_safely(conn.worker)
logger.info("Pool: disconnected session")
def disconnect_all(self) -> None:
with self._lock:
conns = list(self._connections.values())
self._connections.clear()
# Stop all workers outside lock.
for conn in conns:
_stop_worker_safely(conn.worker)
logger.info("Pool: disconnected all sessions")
def cleanup_stale(self) -> None:
now = time.time()
with self._lock:
stale = [
sid
for sid, conn in self._connections.items()
if (now - conn.last_used_at) > self._max_idle_seconds
]
stale_conns = [self._connections.pop(sid) for sid in stale]
# Stop stale workers outside lock.
for conn in stale_conns:
logger.info("Pool: cleaning up stale session")
_stop_worker_safely(conn.worker)
@property
def size(self) -> int:
with self._lock:
return len(self._connections)
def has(self, session_id: str) -> bool:
with self._lock:
return session_id in self._connections
def get_session_info(self, session_id: str) -> tuple[str, str] | None:
"""Return (provider_name, api_key) cached at create time, or None."""
with self._lock:
conn = self._connections.get(session_id)
if conn is None:
return None
return (conn.provider_name, conn.api_key)
# NOTE: _unsafe_disconnect was removed to fix a deadlock.
# Worker shutdown now happens outside the pool lock via _stop_worker_safely().
def _stop_worker_safely(worker: _SubprocessWorker) -> None:
"""Stop a worker outside any pool lock to avoid deadlock with _io_lock."""
try:
worker.stop()
except Exception as stop_err:
logger.debug(
"Pool: error stopping worker during disconnect (%s)",
type(stop_err).__name__,
)
pool = ConnectionPool()
atexit.register(pool.disconnect_all)