Files
actionbook__actionbook/packages/dify-plugin/utils/playwright_worker.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

430 lines
17 KiB
Python

"""Standalone Playwright worker process.
Runs outside Dify's patched runtime and executes browser operations over JSONL.
Supports three selector formats (matching actionbook-rs behavior):
- CSS selectors: ".btn", "#submit", "button:has-text('OK')"
- XPath selectors: "//button[text()='Submit']"
- Snapshot refs: "@e5" or "[ref=e5]" (resolved via DOM re-walk)
"""
from __future__ import annotations
import json
import re
import sys
from typing import Any
from playwright.sync_api import sync_playwright
# Matches @eN or [ref=eN] snapshot reference selectors.
_REF_RE = re.compile(r"^(?:@e(\d+)|\[ref=e(\d+)\])$")
# JavaScript that defines __findElement(selector).
# Ported from actionbook-rs session.rs — supports CSS, XPath, and @eN refs.
_FIND_ELEMENT_JS = r"""
function __findElement(selector) {
// Normalize [ref=eN] format to @eN
const refMatch = selector.match(/^\[ref=(e\d+)\]$/);
if (refMatch) selector = '@' + refMatch[1];
if (/^@e\d+$/.test(selector)) {
const targetNum = parseInt(selector.slice(2));
const SKIP_TAGS = new Set(['script','style','noscript','template','svg','path','defs','clippath','lineargradient','stop','meta','link','br','wbr']);
const INLINE_TAGS = new Set(['strong','b','em','i','code','span','small','sup','sub','abbr','mark','u','s','del','ins','time','q','cite','dfn','var','samp','kbd']);
const INTERACTIVE_ROLES = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']);
const CONTENT_ROLES = new Set(['heading','cell','gridcell','columnheader','rowheader','listitem','article','region','main','navigation','img']);
function getRole(el) {
const explicit = el.getAttribute('role');
if (explicit) return explicit.toLowerCase();
const tag = el.tagName.toLowerCase();
if (INLINE_TAGS.has(tag)) return tag;
const roleMap = {
'a': el.hasAttribute('href') ? 'link' : 'generic',
'button': 'button', 'input': getInputRole(el), 'select': 'combobox', 'textarea': 'textbox', 'img': 'img',
'h1':'heading','h2':'heading','h3':'heading','h4':'heading','h5':'heading','h6':'heading',
'nav':'navigation','main':'main','header':'banner','footer':'contentinfo','aside':'complementary',
'form':'form','table':'table','thead':'rowgroup','tbody':'rowgroup','tfoot':'rowgroup',
'tr':'row','th':'columnheader','td':'cell','ul':'list','ol':'list','li':'listitem',
'details':'group','summary':'button','dialog':'dialog',
'section': el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'region' : 'generic',
'article':'article'
};
return roleMap[tag] || 'generic';
}
function getInputRole(el) {
const type = (el.getAttribute('type') || 'text').toLowerCase();
const map = {'text':'textbox','email':'textbox','password':'textbox','search':'searchbox','tel':'textbox','url':'textbox','number':'spinbutton','checkbox':'checkbox','radio':'radio','submit':'button','reset':'button','button':'button','range':'slider'};
return map[type] || 'textbox';
}
function getAccessibleName(el) {
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) return ariaLabel.trim();
const labelledBy = el.getAttribute('aria-labelledby');
if (labelledBy) { const label = document.getElementById(labelledBy); if (label) return label.textContent?.trim()?.substring(0, 100) || ''; }
const tag = el.tagName.toLowerCase();
if (tag === 'img') return el.getAttribute('alt') || '';
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
if (el.id) { const label = document.querySelector('label[for="' + el.id + '"]'); if (label) return label.textContent?.trim()?.substring(0, 100) || ''; }
return el.getAttribute('placeholder') || el.getAttribute('title') || '';
}
if (tag === 'a' || tag === 'button' || tag === 'summary') return '';
if (['h1','h2','h3','h4','h5','h6'].includes(tag)) return el.textContent?.trim()?.substring(0, 150) || '';
const title = el.getAttribute('title');
if (title) return title.trim();
return '';
}
function isHidden(el) {
if (el.hidden) return true;
if (el.getAttribute('aria-hidden') === 'true') return true;
const style = el.style;
if (style.display === 'none' || style.visibility === 'hidden') return true;
if (el.offsetParent === null && el.tagName.toLowerCase() !== 'body' && getComputedStyle(el).position !== 'fixed' && getComputedStyle(el).position !== 'sticky') {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return true;
}
return false;
}
let refCounter = 0;
function walkFind(el, depth) {
if (depth > 15) return null;
const tag = el.tagName.toLowerCase();
if (SKIP_TAGS.has(tag)) return null;
if (isHidden(el)) return null;
const role = getRole(el);
const name = getAccessibleName(el);
const isInteractive = INTERACTIVE_ROLES.has(role);
const isContent = CONTENT_ROLES.has(role);
const shouldRef = isInteractive || (isContent && name);
if (shouldRef) {
refCounter++;
if (refCounter === targetNum) return el;
}
for (const child of el.children) {
const found = walkFind(child, depth + 1);
if (found) return found;
}
return null;
}
return walkFind(document.body, 0);
}
if (selector.startsWith('//') || selector.startsWith('(//')) {
const result = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
}
return document.querySelector(selector);
}
"""
def _is_ref_selector(selector: str) -> bool:
"""Check if a selector is a snapshot ref (@eN or [ref=eN])."""
return bool(_REF_RE.match(selector.strip()))
def _is_js_selector(selector: str) -> bool:
"""Check if a selector needs JS-based resolution (ref or XPath)."""
s = selector.strip()
return _is_ref_selector(s) or s.startswith("//") or s.startswith("(//")
def _send(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _result(req_id: int, value: Any) -> None:
_send({"id": req_id, "ok": True, "result": value})
def _error(req_id: int, exc: BaseException) -> None:
_send({
"id": req_id,
"ok": False,
"error": f"{type(exc).__name__}: {exc}",
})
def _get_active_page(browser):
contexts = browser.contexts
if not contexts:
context = browser.new_context()
return context.new_page()
context = contexts[0]
pages = context.pages
return pages[0] if pages else context.new_page()
def _js_click(page, selector: str) -> None:
"""Click via JS element resolution + coordinate-based mouse click."""
sel_json = json.dumps(selector)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nconst el = __findElement({sel_json});\n"
"if (!el) return null;\n"
"el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });\n"
"const rect = el.getBoundingClientRect();\n"
"return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };\n"
"})()"
)
coords = page.evaluate(js)
if coords is None:
raise ValueError(f"Element not found for selector: {selector}")
page.mouse.click(coords["x"], coords["y"])
def _js_fill(page, selector: str, text: str) -> None:
"""Fill via JS element resolution — focus, set value, dispatch events."""
sel_json = json.dumps(selector)
text_json = json.dumps(text)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nconst el = __findElement({sel_json});\n"
"if (!el) return false;\n"
"el.focus();\n"
f"el.value = {text_json};\n"
"el.dispatchEvent(new Event('input', { bubbles: true }));\n"
"el.dispatchEvent(new Event('change', { bubbles: true }));\n"
"return true;\n"
"})()"
)
ok = page.evaluate(js)
if not ok:
raise ValueError(f"Element not found for selector: {selector}")
def _js_type(page, selector: str, text: str) -> None:
"""Type via JS element resolution — focus then use keyboard."""
sel_json = json.dumps(selector)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nconst el = __findElement({sel_json});\n"
"if (!el) return false;\n"
"el.focus();\n"
"return true;\n"
"})()"
)
ok = page.evaluate(js)
if not ok:
raise ValueError(f"Element not found for selector: {selector}")
page.keyboard.type(text)
def _js_hover(page, selector: str) -> None:
"""Hover via JS element resolution + coordinate-based mouse move."""
sel_json = json.dumps(selector)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nconst el = __findElement({sel_json});\n"
"if (!el) return null;\n"
"el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });\n"
"const rect = el.getBoundingClientRect();\n"
"return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };\n"
"})()"
)
coords = page.evaluate(js)
if coords is None:
raise ValueError(f"Element not found for selector: {selector}")
page.mouse.move(coords["x"], coords["y"])
def _js_select_option(page, selector: str, value: str) -> list[str]:
"""Select option via JS element resolution."""
sel_json = json.dumps(selector)
val_json = json.dumps(value)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nconst el = __findElement({sel_json});\n"
"if (!el || el.tagName.toLowerCase() !== 'select') return null;\n"
f"const val = {val_json};\n"
"for (const opt of el.options) {\n"
" if (opt.value === val) {\n"
" opt.selected = true;\n"
" el.dispatchEvent(new Event('change', { bubbles: true }));\n"
" return [opt.value];\n"
" }\n"
"}\n"
"return [];\n"
"})()"
)
result = page.evaluate(js)
if result is None:
raise ValueError(f"Element not found for selector: {selector}")
return result
def _js_wait_for_selector(page, selector: str, timeout: float | None) -> None:
"""Wait until a JS-resolved selector appears, honoring timeout."""
sel_json = json.dumps(selector)
js = (
"(function() {\n"
+ _FIND_ELEMENT_JS
+ f"\nreturn __findElement({sel_json}) !== null;\n"
"})()"
)
page.wait_for_function(js, timeout=timeout)
def main() -> int:
if len(sys.argv) < 3:
_send({"ok": False, "error": "Usage: playwright_worker.py <ws_endpoint> <timeout_ms>"})
return 2
ws_endpoint = sys.argv[1]
timeout_ms = float(sys.argv[2])
pw = None
browser = None
page = None
try:
pw = sync_playwright().start()
browser = pw.chromium.connect_over_cdp(ws_endpoint, timeout=timeout_ms)
page = _get_active_page(browser)
_send({"ok": True, "ready": True})
except BaseException as exc:
_send({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
if browser is not None:
try:
browser.close()
except Exception:
pass
if pw is not None:
try:
pw.stop()
except Exception:
pass
return 1
assert page is not None
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError as exc:
_send({"ok": False, "error": f"JSONDecodeError: {exc}"})
continue
req_id = int(payload.get("id", 0))
op = payload.get("op")
params = payload.get("params") or {}
try:
if op == "close":
_result(req_id, "closing")
break
if op == "url":
_result(req_id, page.url)
elif op == "title":
_result(req_id, page.title())
elif op == "goto":
page.goto(
params["url"],
timeout=params.get("timeout"),
wait_until=params.get("wait_until"),
)
_result(req_id, None)
elif op == "wait_for_selector":
selector = params["selector"]
if _is_js_selector(selector):
_js_wait_for_selector(
page,
selector,
timeout=params.get("timeout"),
)
else:
page.wait_for_selector(
selector,
timeout=params.get("timeout"),
)
_result(req_id, None)
elif op == "click":
selector = params["selector"]
if _is_js_selector(selector):
_js_click(page, selector)
else:
page.click(selector)
_result(req_id, None)
elif op == "type":
selector = params["selector"]
if _is_js_selector(selector):
_js_type(page, selector, params["text"])
else:
page.type(selector, params["text"])
_result(req_id, None)
elif op == "fill":
selector = params["selector"]
if _is_js_selector(selector):
_js_fill(page, selector, params["text"])
else:
page.fill(selector, params["text"])
_result(req_id, None)
elif op == "select_option":
selector = params["selector"]
if _is_js_selector(selector):
_result(req_id, _js_select_option(page, selector, params["value"]))
else:
_result(
req_id,
page.select_option(selector, value=params["value"]),
)
elif op == "keyboard_press":
page.keyboard.press(params["key"])
_result(req_id, None)
elif op == "hover":
selector = params["selector"]
if _is_js_selector(selector):
_js_hover(page, selector)
else:
page.hover(selector)
_result(req_id, None)
elif op == "inner_text":
_result(req_id, page.inner_text(params["selector"]))
elif op == "inner_html":
_result(req_id, page.inner_html(params["selector"]))
elif op == "content":
_result(req_id, page.content())
elif op == "wait_for_load_state":
page.wait_for_load_state(
params["state"],
timeout=params.get("timeout"),
)
_result(req_id, None)
elif op == "go_back":
page.go_back()
_result(req_id, None)
elif op == "go_forward":
page.go_forward()
_result(req_id, None)
elif op == "reload":
page.reload(wait_until=params.get("wait_until"))
_result(req_id, None)
elif op == "evaluate":
_result(req_id, page.evaluate(params["expression"]))
else:
raise ValueError(f"Unknown op: {op}")
except BaseException as exc:
_error(req_id, exc)
finally:
try:
browser.close()
except Exception:
pass
try:
pw.stop() # type: ignore[union-attr]
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())