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.
Files changed (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)