openhack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhack/__init__.py +2 -0
- openhack/__main__.py +225 -0
- openhack/agents/__init__.py +30 -0
- openhack/agents/base.py +230 -0
- openhack/agents/browser_verifier.py +679 -0
- openhack/agents/browser_verifier_swarm.py +256 -0
- openhack/agents/checkpoint.py +89 -0
- openhack/agents/context_manager.py +356 -0
- openhack/agents/coordinator.py +1105 -0
- openhack/agents/endpoint_analyst.py +307 -0
- openhack/agents/feature_hunter.py +93 -0
- openhack/agents/hunter.py +481 -0
- openhack/agents/hunter_swarm.py +385 -0
- openhack/agents/llm.py +334 -0
- openhack/agents/recon.py +19 -0
- openhack/agents/sandbox_verifier.py +396 -0
- openhack/agents/sandbox_verifier_swarm.py +250 -0
- openhack/agents/session.py +286 -0
- openhack/agents/validator.py +217 -0
- openhack/agents/validator_swarm.py +106 -0
- openhack/auth.py +175 -0
- openhack/browser/__init__.py +12 -0
- openhack/browser/runner.py +385 -0
- openhack/categories.py +130 -0
- openhack/config.py +201 -0
- openhack/deterministic_recon.py +464 -0
- openhack/entry_points.py +745 -0
- openhack/framework_classifier.py +515 -0
- openhack/framework_detection.py +269 -0
- openhack/headless_scan.py +179 -0
- openhack/prompts/__init__.py +108 -0
- openhack/prompts/browser_verifier.py +171 -0
- openhack/prompts/coordinator.py +31 -0
- openhack/prompts/django/__init__.py +32 -0
- openhack/prompts/django/auth_bypass.py +76 -0
- openhack/prompts/django/csrf.py +62 -0
- openhack/prompts/django/data_exposure.py +67 -0
- openhack/prompts/django/idor.py +74 -0
- openhack/prompts/django/injection.py +67 -0
- openhack/prompts/django/misconfiguration.py +70 -0
- openhack/prompts/django/ssrf.py +64 -0
- openhack/prompts/endpoint_analyst.py +122 -0
- openhack/prompts/express/__init__.py +29 -0
- openhack/prompts/express/auth_bypass.py +71 -0
- openhack/prompts/express/data_exposure.py +77 -0
- openhack/prompts/express/idor.py +69 -0
- openhack/prompts/express/injection.py +75 -0
- openhack/prompts/express/misconfiguration.py +72 -0
- openhack/prompts/express/ssrf.py +63 -0
- openhack/prompts/feature_hunter.py +140 -0
- openhack/prompts/flask/__init__.py +29 -0
- openhack/prompts/flask/auth_bypass.py +86 -0
- openhack/prompts/flask/data_exposure.py +78 -0
- openhack/prompts/flask/idor.py +83 -0
- openhack/prompts/flask/injection.py +77 -0
- openhack/prompts/flask/misconfiguration.py +73 -0
- openhack/prompts/flask/ssrf.py +65 -0
- openhack/prompts/hunter.py +362 -0
- openhack/prompts/hunter_continuation_loop.py +12 -0
- openhack/prompts/hunter_continuation_no_findings.py +19 -0
- openhack/prompts/hunter_continuation_no_progress.py +22 -0
- openhack/prompts/hunter_tool_instructions.py +55 -0
- openhack/prompts/nextjs/__init__.py +42 -0
- openhack/prompts/nextjs/auth_bypass.py +80 -0
- openhack/prompts/nextjs/csrf.py +71 -0
- openhack/prompts/nextjs/data_exposure.py +88 -0
- openhack/prompts/nextjs/idor.py +64 -0
- openhack/prompts/nextjs/injection.py +65 -0
- openhack/prompts/nextjs/middleware_bypass.py +75 -0
- openhack/prompts/nextjs/misconfiguration.py +92 -0
- openhack/prompts/nextjs/server_actions.py +97 -0
- openhack/prompts/nextjs/ssrf.py +66 -0
- openhack/prompts/nextjs/xss.py +69 -0
- openhack/prompts/pr_analysis_system.py +80 -0
- openhack/prompts/pr_analysis_user.py +11 -0
- openhack/prompts/project_context.py +89 -0
- openhack/prompts/recon.py +199 -0
- openhack/prompts/reporter.py +88 -0
- openhack/prompts/researchers.py +434 -0
- openhack/prompts/sandbox_verifier.py +128 -0
- openhack/prompts/supabase/__init__.py +39 -0
- openhack/prompts/supabase/auth_tokens.py +131 -0
- openhack/prompts/supabase/edge_functions.py +150 -0
- openhack/prompts/supabase/graphql.py +102 -0
- openhack/prompts/supabase/postgrest.py +99 -0
- openhack/prompts/supabase/realtime.py +93 -0
- openhack/prompts/supabase/rls.py +110 -0
- openhack/prompts/supabase/rpc_functions.py +127 -0
- openhack/prompts/supabase/storage.py +110 -0
- openhack/prompts/supabase/tenant_isolation.py +118 -0
- openhack/prompts/validator.py +319 -0
- openhack/prompts/validator_continuation_incomplete.py +12 -0
- openhack/prompts/validator_tool_instructions.py +29 -0
- openhack/quality.py +231 -0
- openhack/sandbox/__init__.py +12 -0
- openhack/sandbox/orchestrator.py +517 -0
- openhack/sandbox/runner.py +177 -0
- openhack/scan_session.py +245 -0
- openhack/setup.py +452 -0
- openhack/static_validator.py +612 -0
- openhack/tools/__init__.py +1 -0
- openhack/tools/ast_tools.py +307 -0
- openhack/tools/coverage.py +1078 -0
- openhack/tools/filesystem.py +404 -0
- openhack/tools/nextjs.py +258 -0
- openhack/tools/registry.py +52 -0
- openhack/tui.py +3450 -0
- openhack/updates.py +170 -0
- openhack-0.1.0.dist-info/METADATA +189 -0
- openhack-0.1.0.dist-info/RECORD +113 -0
- openhack-0.1.0.dist-info/WHEEL +4 -0
- openhack-0.1.0.dist-info/entry_points.txt +2 -0
- openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/auth.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenHack device-code login flow.
|
|
3
|
+
|
|
4
|
+
Talks to the web app's CLI auth endpoints:
|
|
5
|
+
POST /api/cli/auth — start session, returns device_code + user_code
|
|
6
|
+
POST /api/cli/auth/poll — poll for approval, returns token on success
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import webbrowser
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit import print_formatted_text
|
|
17
|
+
from prompt_toolkit.formatted_text import HTML
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
POLL_INTERVAL_SECONDS = 2
|
|
21
|
+
MAX_POLL_SECONDS = 600
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeviceLoginError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DeviceLoginExpired(DeviceLoginError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeviceLoginCancelled(DeviceLoginError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DeviceCodeStart:
|
|
38
|
+
device_code: str
|
|
39
|
+
user_code: str
|
|
40
|
+
verification_url: str
|
|
41
|
+
expires_in: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LoginResult:
|
|
46
|
+
token: str
|
|
47
|
+
org_id: Optional[str] = None
|
|
48
|
+
org_slug: Optional[str] = None
|
|
49
|
+
org_name: Optional[str] = None
|
|
50
|
+
user_email: Optional[str] = None
|
|
51
|
+
user_first_name: Optional[str] = None
|
|
52
|
+
user_last_name: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _html(text: str) -> None:
|
|
56
|
+
print_formatted_text(HTML(text))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _esc(text: str) -> str:
|
|
60
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _start_device_flow(session: aiohttp.ClientSession, app_url: str) -> DeviceCodeStart:
|
|
64
|
+
url = f"{app_url.rstrip('/')}/api/cli/auth"
|
|
65
|
+
try:
|
|
66
|
+
async with session.post(url) as resp:
|
|
67
|
+
if resp.status != 200:
|
|
68
|
+
body = await resp.text()
|
|
69
|
+
raise DeviceLoginError(f"Failed to start login (HTTP {resp.status}): {body[:200]}")
|
|
70
|
+
data = await resp.json()
|
|
71
|
+
except aiohttp.ClientConnectorError as exc:
|
|
72
|
+
raise DeviceLoginError(
|
|
73
|
+
f"Could not reach OpenHack at {app_url}. Is the app running?"
|
|
74
|
+
) from exc
|
|
75
|
+
except aiohttp.ClientError as exc:
|
|
76
|
+
raise DeviceLoginError(f"Network error talking to {app_url}: {exc}") from exc
|
|
77
|
+
return DeviceCodeStart(
|
|
78
|
+
device_code=data["device_code"],
|
|
79
|
+
user_code=data["user_code"],
|
|
80
|
+
verification_url=data["verification_url"],
|
|
81
|
+
expires_in=int(data.get("expires_in", 900)),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _poll_once(session: aiohttp.ClientSession, app_url: str, device_code: str) -> tuple[str, Optional[dict]]:
|
|
86
|
+
"""Returns (status, payload). status ∈ {pending, approved, expired}.
|
|
87
|
+
|
|
88
|
+
payload (on approved) is the full poll response: {token, org: {id, slug, name}}.
|
|
89
|
+
"""
|
|
90
|
+
url = f"{app_url.rstrip('/')}/api/cli/auth/poll"
|
|
91
|
+
try:
|
|
92
|
+
async with session.post(url, json={"device_code": device_code}) as resp:
|
|
93
|
+
if resp.status == 410:
|
|
94
|
+
return ("expired", None)
|
|
95
|
+
if resp.status != 200:
|
|
96
|
+
body = await resp.text()
|
|
97
|
+
raise DeviceLoginError(f"Poll failed (HTTP {resp.status}): {body[:200]}")
|
|
98
|
+
data = await resp.json()
|
|
99
|
+
except aiohttp.ClientError:
|
|
100
|
+
# Transient network blip — surface as pending so polling continues.
|
|
101
|
+
return ("pending", None)
|
|
102
|
+
|
|
103
|
+
status = data.get("status", "")
|
|
104
|
+
if status == "approved":
|
|
105
|
+
return ("approved", data)
|
|
106
|
+
if status == "pending":
|
|
107
|
+
return ("pending", None)
|
|
108
|
+
return (status or "unknown", None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def device_login(app_url: str) -> LoginResult:
|
|
112
|
+
"""Run the device-code login flow. Returns token + org context.
|
|
113
|
+
|
|
114
|
+
Raises DeviceLoginError on failure, DeviceLoginCancelled on user interrupt.
|
|
115
|
+
"""
|
|
116
|
+
timeout = aiohttp.ClientTimeout(total=30)
|
|
117
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
118
|
+
start = await _start_device_flow(session, app_url)
|
|
119
|
+
|
|
120
|
+
_html("")
|
|
121
|
+
_html(f' <b><ansicyan>Login with OpenHack</ansicyan></b>')
|
|
122
|
+
_html("")
|
|
123
|
+
_html(f' Your verification code: <b><ansiyellow>{_esc(start.user_code)}</ansiyellow></b>')
|
|
124
|
+
_html("")
|
|
125
|
+
_html(f' <ansigray>Opening browser at:</ansigray>')
|
|
126
|
+
_html(f' <ansigray>{_esc(start.verification_url)}</ansigray>')
|
|
127
|
+
_html("")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
webbrowser.open(start.verification_url)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
_html(f' <ansigray>Waiting for approval... (Ctrl+C to cancel)</ansigray>')
|
|
135
|
+
_html("")
|
|
136
|
+
|
|
137
|
+
elapsed = 0
|
|
138
|
+
try:
|
|
139
|
+
while elapsed < MAX_POLL_SECONDS:
|
|
140
|
+
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
|
141
|
+
elapsed += POLL_INTERVAL_SECONDS
|
|
142
|
+
|
|
143
|
+
status, payload = await _poll_once(session, app_url, start.device_code)
|
|
144
|
+
|
|
145
|
+
if status == "approved":
|
|
146
|
+
if not payload or not payload.get("token"):
|
|
147
|
+
raise DeviceLoginError("Approval succeeded but no token was returned.")
|
|
148
|
+
org = payload.get("org") or {}
|
|
149
|
+
user = payload.get("user") or {}
|
|
150
|
+
result = LoginResult(
|
|
151
|
+
token=payload["token"],
|
|
152
|
+
org_id=org.get("id"),
|
|
153
|
+
org_slug=org.get("slug"),
|
|
154
|
+
org_name=org.get("name"),
|
|
155
|
+
user_email=user.get("email"),
|
|
156
|
+
user_first_name=user.get("firstName"),
|
|
157
|
+
user_last_name=user.get("lastName"),
|
|
158
|
+
)
|
|
159
|
+
org_name = result.org_name or "(no org)"
|
|
160
|
+
_html(f' <b><ansigreen>✓</ansigreen></b> Logged in to <b>{_esc(org_name)}</b>.')
|
|
161
|
+
_html("")
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
if status == "expired":
|
|
165
|
+
raise DeviceLoginExpired(
|
|
166
|
+
"Login code expired before approval. Please run setup again."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# status == "pending" — keep polling
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
raise DeviceLoginCancelled("Login cancelled.")
|
|
172
|
+
except KeyboardInterrupt:
|
|
173
|
+
raise DeviceLoginCancelled("Login cancelled.")
|
|
174
|
+
|
|
175
|
+
raise DeviceLoginExpired("Login timed out waiting for approval.")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser-based verification layer for OpenHack.
|
|
3
|
+
|
|
4
|
+
Drives a headless Chromium browser via Playwright to verify
|
|
5
|
+
vulnerabilities that require real browser interaction: XSS
|
|
6
|
+
confirmation via DOM inspection, CSRF token handling, login
|
|
7
|
+
flows, multi-step UI interactions, and screenshot evidence.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .runner import BrowserRunner, BrowserResult
|
|
11
|
+
|
|
12
|
+
__all__ = ["BrowserRunner", "BrowserResult"]
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser runner for browser-based verification.
|
|
3
|
+
|
|
4
|
+
Drives a Playwright Chromium browser to verify vulnerabilities
|
|
5
|
+
that require real browser interaction. Counterpart to sandbox/runner.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class BrowserResult:
|
|
20
|
+
"""Result of a browser action."""
|
|
21
|
+
success: bool
|
|
22
|
+
page_url: str = ""
|
|
23
|
+
page_title: str = ""
|
|
24
|
+
page_content: str = ""
|
|
25
|
+
console_logs: list[str] = field(default_factory=list)
|
|
26
|
+
screenshot_path: Optional[str] = None
|
|
27
|
+
elapsed_ms: float = 0.0
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
d: dict[str, Any] = {
|
|
32
|
+
"success": self.success,
|
|
33
|
+
"elapsed_ms": round(self.elapsed_ms, 1),
|
|
34
|
+
}
|
|
35
|
+
if self.page_url:
|
|
36
|
+
d["page_url"] = self.page_url
|
|
37
|
+
if self.page_title:
|
|
38
|
+
d["page_title"] = self.page_title
|
|
39
|
+
if self.page_content:
|
|
40
|
+
d["page_content"] = self.page_content[:5000]
|
|
41
|
+
if self.console_logs:
|
|
42
|
+
d["console_logs"] = self.console_logs[-20:]
|
|
43
|
+
if self.screenshot_path:
|
|
44
|
+
d["screenshot_path"] = self.screenshot_path
|
|
45
|
+
if self.error:
|
|
46
|
+
d["error"] = self.error
|
|
47
|
+
return d
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BrowserContext:
|
|
51
|
+
"""Isolated browser context for a single finding verification."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, context, page, evidence_dir: Path):
|
|
54
|
+
self.context = context
|
|
55
|
+
self.page = page
|
|
56
|
+
self.evidence_dir = evidence_dir
|
|
57
|
+
self.console_logs: list[str] = []
|
|
58
|
+
self._screenshot_counter = 0
|
|
59
|
+
|
|
60
|
+
page.on("console", lambda msg: self.console_logs.append(
|
|
61
|
+
f"[{msg.type}] {msg.text}"
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def next_screenshot_index(self) -> int:
|
|
66
|
+
self._screenshot_counter += 1
|
|
67
|
+
return self._screenshot_counter
|
|
68
|
+
|
|
69
|
+
async def close(self):
|
|
70
|
+
try:
|
|
71
|
+
await self.context.close()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BrowserRunner:
|
|
77
|
+
"""Drives a Playwright browser for exploit verification."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
base_url: str,
|
|
82
|
+
evidence_dir: Path,
|
|
83
|
+
headless: bool = True,
|
|
84
|
+
timeout: int = 30000,
|
|
85
|
+
):
|
|
86
|
+
try:
|
|
87
|
+
from playwright.async_api import async_playwright # noqa: F401
|
|
88
|
+
except ImportError:
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"Playwright is required for browser verification.\n"
|
|
91
|
+
"Install with:\n"
|
|
92
|
+
" pip install playwright\n"
|
|
93
|
+
" playwright install chromium"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.base_url = base_url.rstrip("/")
|
|
97
|
+
self.evidence_dir = evidence_dir
|
|
98
|
+
self.headless = headless
|
|
99
|
+
self.timeout = timeout
|
|
100
|
+
self._playwright = None
|
|
101
|
+
self._browser = None
|
|
102
|
+
|
|
103
|
+
async def __aenter__(self):
|
|
104
|
+
from playwright.async_api import async_playwright
|
|
105
|
+
|
|
106
|
+
self._playwright = await async_playwright().start()
|
|
107
|
+
try:
|
|
108
|
+
self._browser = await self._playwright.chromium.launch(
|
|
109
|
+
headless=self.headless,
|
|
110
|
+
)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
await self._playwright.stop()
|
|
113
|
+
self._playwright = None
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"Failed to launch Chromium: {e}\n"
|
|
116
|
+
"Run: playwright install chromium"
|
|
117
|
+
) from e
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
121
|
+
if self._browser:
|
|
122
|
+
await self._browser.close()
|
|
123
|
+
if self._playwright:
|
|
124
|
+
await self._playwright.stop()
|
|
125
|
+
|
|
126
|
+
async def create_context(self, finding_index: int) -> "BrowserContext":
|
|
127
|
+
ctx_evidence_dir = self.evidence_dir / f"finding_{finding_index}"
|
|
128
|
+
ctx_evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
context = await self._browser.new_context(
|
|
131
|
+
ignore_https_errors=True,
|
|
132
|
+
viewport={"width": 1280, "height": 720},
|
|
133
|
+
)
|
|
134
|
+
context.set_default_timeout(self.timeout)
|
|
135
|
+
page = await context.new_page()
|
|
136
|
+
|
|
137
|
+
return BrowserContext(context, page, ctx_evidence_dir)
|
|
138
|
+
|
|
139
|
+
async def navigate(
|
|
140
|
+
self, ctx: BrowserContext, url: str, wait_until: str = "networkidle",
|
|
141
|
+
) -> BrowserResult:
|
|
142
|
+
start = time.time()
|
|
143
|
+
full_url = url if url.startswith("http") else f"{self.base_url}{url}"
|
|
144
|
+
try:
|
|
145
|
+
await ctx.page.goto(full_url, wait_until=wait_until)
|
|
146
|
+
elapsed = (time.time() - start) * 1000
|
|
147
|
+
snap = await self.snapshot(ctx)
|
|
148
|
+
return BrowserResult(
|
|
149
|
+
success=True,
|
|
150
|
+
page_url=ctx.page.url,
|
|
151
|
+
page_title=await ctx.page.title(),
|
|
152
|
+
page_content=snap.get("snapshot", ""),
|
|
153
|
+
console_logs=list(ctx.console_logs),
|
|
154
|
+
elapsed_ms=elapsed,
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
return BrowserResult(
|
|
158
|
+
success=False,
|
|
159
|
+
page_url=ctx.page.url,
|
|
160
|
+
error=str(e),
|
|
161
|
+
elapsed_ms=(time.time() - start) * 1000,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def click(
|
|
165
|
+
self, ctx: BrowserContext, selector: str, selector_type: str = "css",
|
|
166
|
+
) -> BrowserResult:
|
|
167
|
+
start = time.time()
|
|
168
|
+
click_error = None
|
|
169
|
+
try:
|
|
170
|
+
locator = self._resolve_locator(ctx, selector, selector_type)
|
|
171
|
+
await locator.click()
|
|
172
|
+
await ctx.page.wait_for_load_state("networkidle", timeout=5000)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
click_error = str(e)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
elapsed = (time.time() - start) * 1000
|
|
178
|
+
snap = await self.snapshot(ctx)
|
|
179
|
+
return BrowserResult(
|
|
180
|
+
success=click_error is None,
|
|
181
|
+
page_url=ctx.page.url,
|
|
182
|
+
page_title=await ctx.page.title(),
|
|
183
|
+
page_content=snap.get("snapshot", ""),
|
|
184
|
+
console_logs=list(ctx.console_logs),
|
|
185
|
+
error=click_error,
|
|
186
|
+
elapsed_ms=elapsed,
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return BrowserResult(
|
|
190
|
+
success=False,
|
|
191
|
+
page_url=ctx.page.url,
|
|
192
|
+
error=f"Click failed: {click_error or e}",
|
|
193
|
+
elapsed_ms=(time.time() - start) * 1000,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def fill(
|
|
197
|
+
self, ctx: BrowserContext, selector: str, value: str,
|
|
198
|
+
) -> BrowserResult:
|
|
199
|
+
start = time.time()
|
|
200
|
+
css = self._selector_to_css(selector)
|
|
201
|
+
try:
|
|
202
|
+
await ctx.page.fill(css, value)
|
|
203
|
+
elapsed = (time.time() - start) * 1000
|
|
204
|
+
return BrowserResult(
|
|
205
|
+
success=True,
|
|
206
|
+
page_url=ctx.page.url,
|
|
207
|
+
elapsed_ms=elapsed,
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
return BrowserResult(
|
|
211
|
+
success=False,
|
|
212
|
+
page_url=ctx.page.url,
|
|
213
|
+
error=f"Fill failed: {e}",
|
|
214
|
+
elapsed_ms=(time.time() - start) * 1000,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def screenshot(
|
|
218
|
+
self, ctx: BrowserContext, name: str,
|
|
219
|
+
) -> dict:
|
|
220
|
+
idx = ctx.next_screenshot_index
|
|
221
|
+
filename = f"{idx:02d}_{name}.png"
|
|
222
|
+
path = ctx.evidence_dir / filename
|
|
223
|
+
try:
|
|
224
|
+
await ctx.page.screenshot(path=str(path), full_page=True)
|
|
225
|
+
return {"path": str(path), "saved": True}
|
|
226
|
+
except Exception as e:
|
|
227
|
+
return {"path": "", "saved": False, "error": str(e)}
|
|
228
|
+
|
|
229
|
+
async def get_content(
|
|
230
|
+
self,
|
|
231
|
+
ctx: BrowserContext,
|
|
232
|
+
selector: Optional[str] = None,
|
|
233
|
+
fmt: str = "text",
|
|
234
|
+
) -> dict:
|
|
235
|
+
try:
|
|
236
|
+
if selector:
|
|
237
|
+
element = ctx.page.locator(selector)
|
|
238
|
+
if fmt == "html":
|
|
239
|
+
content = await element.inner_html()
|
|
240
|
+
else:
|
|
241
|
+
content = await element.inner_text()
|
|
242
|
+
else:
|
|
243
|
+
if fmt == "html":
|
|
244
|
+
content = await ctx.page.content()
|
|
245
|
+
else:
|
|
246
|
+
content = await ctx.page.inner_text("body")
|
|
247
|
+
return {"content": content[:5000], "url": ctx.page.url}
|
|
248
|
+
except Exception as e:
|
|
249
|
+
return {"content": "", "url": ctx.page.url, "error": str(e)}
|
|
250
|
+
|
|
251
|
+
async def execute_js(
|
|
252
|
+
self, ctx: BrowserContext, script: str,
|
|
253
|
+
) -> dict:
|
|
254
|
+
try:
|
|
255
|
+
result = await ctx.page.evaluate(script)
|
|
256
|
+
serialized = json.dumps(result, default=str) if result is not None else "null"
|
|
257
|
+
return {"result": serialized[:5000]}
|
|
258
|
+
except Exception as e:
|
|
259
|
+
return {"result": None, "error": str(e)}
|
|
260
|
+
|
|
261
|
+
async def wait_for(
|
|
262
|
+
self,
|
|
263
|
+
ctx: BrowserContext,
|
|
264
|
+
selector: str,
|
|
265
|
+
timeout: int = 5000,
|
|
266
|
+
state: str = "visible",
|
|
267
|
+
) -> dict:
|
|
268
|
+
start = time.time()
|
|
269
|
+
try:
|
|
270
|
+
await ctx.page.wait_for_selector(selector, timeout=timeout, state=state)
|
|
271
|
+
return {
|
|
272
|
+
"found": True,
|
|
273
|
+
"elapsed_ms": round((time.time() - start) * 1000, 1),
|
|
274
|
+
}
|
|
275
|
+
except Exception:
|
|
276
|
+
return {
|
|
277
|
+
"found": False,
|
|
278
|
+
"elapsed_ms": round((time.time() - start) * 1000, 1),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async def get_cookies(self, ctx: BrowserContext) -> dict:
|
|
282
|
+
try:
|
|
283
|
+
cookies = await ctx.context.cookies()
|
|
284
|
+
safe_cookies = []
|
|
285
|
+
for c in cookies:
|
|
286
|
+
safe_cookies.append({
|
|
287
|
+
"name": c.get("name"),
|
|
288
|
+
"domain": c.get("domain"),
|
|
289
|
+
"path": c.get("path"),
|
|
290
|
+
"httpOnly": c.get("httpOnly"),
|
|
291
|
+
"secure": c.get("secure"),
|
|
292
|
+
"sameSite": c.get("sameSite"),
|
|
293
|
+
"expires": c.get("expires"),
|
|
294
|
+
})
|
|
295
|
+
return {"cookies": safe_cookies}
|
|
296
|
+
except Exception as e:
|
|
297
|
+
return {"cookies": [], "error": str(e)}
|
|
298
|
+
|
|
299
|
+
async def snapshot(self, ctx: BrowserContext) -> dict:
|
|
300
|
+
"""Tag every interactive element with @eN refs and return a compact map.
|
|
301
|
+
|
|
302
|
+
After calling this, the agent can use refs like @e3 in click/fill instead
|
|
303
|
+
of guessing CSS selectors. Refs persist on the page until the next snapshot
|
|
304
|
+
or navigation.
|
|
305
|
+
"""
|
|
306
|
+
snapshot_js = r"""
|
|
307
|
+
(() => {
|
|
308
|
+
document.querySelectorAll('[data-openhack-ref]').forEach(el => el.removeAttribute('data-openhack-ref'));
|
|
309
|
+
const sel = 'a[href], button, input, textarea, select, '
|
|
310
|
+
+ '[role="button"], [role="link"], [role="checkbox"], [role="radio"], '
|
|
311
|
+
+ '[role="textbox"], [role="combobox"], [role="menuitem"], [role="tab"], '
|
|
312
|
+
+ '[onclick], [contenteditable="true"]';
|
|
313
|
+
const els = document.querySelectorAll(sel);
|
|
314
|
+
const out = [];
|
|
315
|
+
let n = 0;
|
|
316
|
+
for (const el of els) {
|
|
317
|
+
const rect = el.getBoundingClientRect();
|
|
318
|
+
const style = getComputedStyle(el);
|
|
319
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
320
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
321
|
+
n++;
|
|
322
|
+
const ref = 'e' + n;
|
|
323
|
+
el.setAttribute('data-openhack-ref', ref);
|
|
324
|
+
const text = ((el.innerText || '').trim()
|
|
325
|
+
|| el.value
|
|
326
|
+
|| el.placeholder
|
|
327
|
+
|| el.getAttribute('aria-label')
|
|
328
|
+
|| el.getAttribute('title')
|
|
329
|
+
|| '').toString().replace(/\s+/g, ' ').slice(0, 80);
|
|
330
|
+
out.push({
|
|
331
|
+
ref: '@' + ref,
|
|
332
|
+
tag: el.tagName.toLowerCase(),
|
|
333
|
+
type: el.type || '',
|
|
334
|
+
role: el.getAttribute('role') || '',
|
|
335
|
+
name: el.name || el.id || '',
|
|
336
|
+
text,
|
|
337
|
+
href: el.tagName === 'A' ? (el.getAttribute('href') || '') : '',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return {count: n, elements: out, url: location.href, title: document.title};
|
|
341
|
+
})()
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
result = await ctx.page.evaluate(snapshot_js)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
return {"snapshot": "", "count": 0, "error": str(e)}
|
|
348
|
+
|
|
349
|
+
lines = [f"Page: {result['title']} ({result['url']})"]
|
|
350
|
+
lines.append(f"{result['count']} interactive element(s):")
|
|
351
|
+
for el in result["elements"]:
|
|
352
|
+
parts = [el["ref"], f"<{el['tag']}"]
|
|
353
|
+
if el["type"]:
|
|
354
|
+
parts.append(f"type={el['type']!r}")
|
|
355
|
+
if el["role"]:
|
|
356
|
+
parts.append(f"role={el['role']!r}")
|
|
357
|
+
if el["name"]:
|
|
358
|
+
parts.append(f"name={el['name']!r}")
|
|
359
|
+
if el["href"]:
|
|
360
|
+
parts.append(f"href={el['href']!r}")
|
|
361
|
+
head = " ".join(parts) + ">"
|
|
362
|
+
tail = f' "{el["text"]}"' if el["text"] else ""
|
|
363
|
+
lines.append(f" {head}{tail}")
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"snapshot": "\n".join(lines),
|
|
367
|
+
"count": result["count"],
|
|
368
|
+
"url": result["url"],
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def _selector_to_css(self, selector: str) -> str:
|
|
372
|
+
"""Convert a @eN ref to a [data-openhack-ref=eN] CSS selector. Pass-through otherwise."""
|
|
373
|
+
if selector.startswith("@e") and selector[2:].isdigit():
|
|
374
|
+
return f'[data-openhack-ref="{selector[1:]}"]'
|
|
375
|
+
return selector
|
|
376
|
+
|
|
377
|
+
def _resolve_locator(self, ctx: BrowserContext, selector: str, selector_type: str):
|
|
378
|
+
if selector.startswith("@e") and selector[2:].isdigit():
|
|
379
|
+
return ctx.page.locator(f'[data-openhack-ref="{selector[1:]}"]')
|
|
380
|
+
if selector_type == "text":
|
|
381
|
+
return ctx.page.get_by_text(selector)
|
|
382
|
+
elif selector_type == "role":
|
|
383
|
+
return ctx.page.get_by_role(selector)
|
|
384
|
+
else:
|
|
385
|
+
return ctx.page.locator(selector)
|