narada 0.1.28__py3-none-any.whl → 0.1.29__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.
narada/__init__.py CHANGED
@@ -9,7 +9,7 @@ from narada_core.errors import (
9
9
  from narada_core.models import Agent, File, Response, ResponseContent
10
10
 
11
11
  from narada.client import Narada
12
- from narada.config import BrowserConfig
12
+ from narada.config import BrowserConfig, ProxyConfig
13
13
  from narada.utils import download_file, render_html
14
14
  from narada.version import __version__
15
15
  from narada.window import LocalBrowserWindow, RemoteBrowserWindow
@@ -28,6 +28,7 @@ __all__ = [
28
28
  "NaradaInitializationError",
29
29
  "NaradaTimeoutError",
30
30
  "NaradaUnsupportedBrowserError",
31
+ "ProxyConfig",
31
32
  "RemoteBrowserWindow",
32
33
  "render_html",
33
34
  "Response",
narada/client.py CHANGED
@@ -21,6 +21,8 @@ from narada_core.errors import (
21
21
  from narada_core.models import _SdkConfig
22
22
  from playwright._impl._errors import Error as PlaywrightError
23
23
  from playwright.async_api import (
24
+ Browser,
25
+ CDPSession,
24
26
  ElementHandle,
25
27
  Page,
26
28
  Playwright,
@@ -32,7 +34,7 @@ from playwright.async_api import (
32
34
  from playwright.async_api._context_manager import PlaywrightContextManager
33
35
  from rich.console import Console
34
36
 
35
- from narada.config import BrowserConfig
37
+ from narada.config import BrowserConfig, ProxyConfig
36
38
  from narada.utils import assert_never
37
39
  from narada.version import __version__
38
40
  from narada.window import LocalBrowserWindow, create_side_panel_url
@@ -146,6 +148,13 @@ class Narada:
146
148
 
147
149
  config = config or BrowserConfig()
148
150
 
151
+ if config.proxy is not None:
152
+ raise ValueError(
153
+ "Proxy configuration is not supported for `initialize_in_existing_browser_window`. "
154
+ "Proxy settings must be specified when launching Chrome. "
155
+ "Use `open_and_initialize_browser_window` instead."
156
+ )
157
+
149
158
  browser = await playwright.chromium.connect_over_cdp(config.cdp_url)
150
159
 
151
160
  # Generate a unique tag for the initialization URL
@@ -193,6 +202,13 @@ class Narada:
193
202
  window_tag = uuid4().hex
194
203
  tagged_initialization_url = f"{config.initialization_url}?t={window_tag}"
195
204
 
205
+ # When proxy auth is needed, launch with about:blank to avoid Chrome's startup auth prompt.
206
+ # We'll set up the CDP auth handler and then navigate to the init URL.
207
+ proxy_requires_auth = (
208
+ config.proxy is not None and config.proxy.requires_authentication
209
+ )
210
+ launch_url = "about:blank" if proxy_requires_auth else tagged_initialization_url
211
+
196
212
  browser_args = [
197
213
  f"--user-data-dir={config.user_data_dir}",
198
214
  f"--profile-directory={config.profile_directory}",
@@ -200,11 +216,20 @@ class Narada:
200
216
  "--no-default-browser-check",
201
217
  "--no-first-run",
202
218
  "--new-window",
203
- tagged_initialization_url,
204
- # TODO: This is needed if we don't use CDP but let Playwright manage the browser.
205
- # "--disable-blink-features=AutomationControlled",
219
+ launch_url,
206
220
  ]
207
221
 
222
+ # Add proxy arguments if configured.
223
+ if config.proxy is not None:
224
+ config.proxy.validate()
225
+ browser_args.append(f"--proxy-server={config.proxy.server}")
226
+
227
+ if config.proxy.bypass:
228
+ browser_args.append(f"--proxy-bypass-list={config.proxy.bypass}")
229
+
230
+ if config.proxy.ignore_cert_errors:
231
+ browser_args.append("--ignore-certificate-errors")
232
+
208
233
  # Launch an independent browser process which will not be killed when the current program
209
234
  # exits.
210
235
  if sys.platform == "win32":
@@ -235,6 +260,14 @@ class Narada:
235
260
  browser_window_id = None
236
261
  side_panel_page = None
237
262
  max_cdp_connect_attempts = 10
263
+
264
+ # Track whether we've already navigated from about:blank to the initialization URL.
265
+ # This is only relevant when proxy auth is enabled, where we launch with about:blank
266
+ # to set up CDP auth handlers before any network traffic. We must only navigate once,
267
+ # because on retry iterations context.pages[0] could be any page (side panel, devtools,
268
+ # etc.) and navigating it would break the initialization flow.
269
+ did_initial_navigation = False
270
+
238
271
  for attempt in range(max_cdp_connect_attempts):
239
272
  try:
240
273
  browser = await playwright.chromium.connect_over_cdp(config.cdp_url)
@@ -246,8 +279,23 @@ class Narada:
246
279
  await asyncio.sleep(2)
247
280
  continue
248
281
 
249
- # Grab the browser window ID from the page we just opened.
250
282
  context = browser.contexts[0]
283
+
284
+ # If proxy auth is needed, set up the handler at browser level then navigate to the
285
+ # initialization page. After navigation succeeds, Chrome has cached the proxy
286
+ # credentials, so we can detach the CDP session.
287
+ if proxy_requires_auth and not did_initial_navigation:
288
+ proxy_cdp_session = (
289
+ await self._setup_proxy_authentication_browser_level(
290
+ browser, config.proxy
291
+ )
292
+ )
293
+ blank_page = context.pages[0]
294
+ await blank_page.goto(tagged_initialization_url)
295
+ await proxy_cdp_session.detach()
296
+ did_initial_navigation = True
297
+
298
+ # Grab the browser window ID from the page we just opened.
251
299
  initialization_page = next(
252
300
  (p for p in context.pages if p.url == tagged_initialization_url), None
253
301
  )
@@ -415,6 +463,73 @@ class Narada:
415
463
  initialization_page
416
464
  )
417
465
 
466
+ async def _setup_proxy_authentication_browser_level(
467
+ self, browser: Browser, proxy_config: ProxyConfig
468
+ ) -> CDPSession:
469
+ """Sets up proxy authentication handling via CDP at the browser level.
470
+
471
+ This uses a browser-level CDP session which can intercept auth challenges before they reach
472
+ individual pages, preventing Chrome from showing the proxy authentication dialog.
473
+
474
+ Chrome caches proxy credentials for the session after the first successful authentication.
475
+ The caller should detach the returned CDP session after the first navigation succeeds.
476
+ """
477
+ cdp_session = await browser.new_browser_cdp_session()
478
+
479
+ # Enable Fetch domain with a catch-all pattern to intercept auth challenges.
480
+ await cdp_session.send(
481
+ "Fetch.enable",
482
+ {
483
+ "handleAuthRequests": True,
484
+ "patterns": [{"urlPattern": "*"}],
485
+ },
486
+ )
487
+
488
+ async def handle_auth(params: dict[str, Any]) -> None:
489
+ request_id = params.get("requestId")
490
+ auth_challenge = params.get("authChallenge", {})
491
+
492
+ # Only handle proxy auth challenges
493
+ if auth_challenge.get("source") != "Proxy":
494
+ return
495
+
496
+ try:
497
+ await cdp_session.send(
498
+ "Fetch.continueWithAuth",
499
+ {
500
+ "requestId": request_id,
501
+ "authChallengeResponse": {
502
+ "response": "ProvideCredentials",
503
+ "username": proxy_config.username,
504
+ "password": proxy_config.password,
505
+ },
506
+ },
507
+ )
508
+ logging.debug("Browser-level proxy authentication credentials provided")
509
+ except Exception as e:
510
+ logging.error("Failed to respond to proxy auth challenge: %s", e)
511
+
512
+ async def handle_request_paused(params: dict[str, Any]) -> None:
513
+ # Continue all paused requests immediately
514
+ request_id = params.get("requestId")
515
+ try:
516
+ await cdp_session.send(
517
+ "Fetch.continueRequest", {"requestId": request_id}
518
+ )
519
+ except Exception:
520
+ pass
521
+
522
+ cdp_session.on(
523
+ "Fetch.authRequired",
524
+ lambda params: asyncio.create_task(handle_auth(params)),
525
+ )
526
+ cdp_session.on(
527
+ "Fetch.requestPaused",
528
+ lambda params: asyncio.create_task(handle_request_paused(params)),
529
+ )
530
+
531
+ return cdp_session
532
+
418
533
  async def _fix_download_behavior(self, side_panel_page: Page) -> None:
419
534
  """Reverts the download behavior to the default behavior for the extension, otherwise our
420
535
  extension cannot download files.
narada/config.py CHANGED
@@ -22,6 +22,50 @@ def _default_user_data_dir() -> str:
22
22
  return str(Path("~/.config/narada/user-data-dirs/default").expanduser())
23
23
 
24
24
 
25
+ @dataclass
26
+ class ProxyConfig:
27
+ """Configuration for HTTP/HTTPS/SOCKS5 proxy.
28
+
29
+ Args:
30
+ server: Proxy server URL. HTTP and SOCKS proxies are supported, for example
31
+ "http://myproxy.com:3128" or "socks5://myproxy.com:3128".
32
+ Short form "myproxy.com:3128" is considered an HTTP proxy.
33
+ username: Optional username for proxy authentication.
34
+ password: Optional password for proxy authentication.
35
+ bypass: Optional comma-separated domains to bypass proxy,
36
+ for example ".com, chromium.org, .domain.com".
37
+ ignore_cert_errors: If True, ignore SSL certificate errors. Required for proxies that
38
+ perform HTTPS inspection (MITM). Use with caution.
39
+ """
40
+
41
+ server: str
42
+ username: str | None = None
43
+ password: str | None = None
44
+ bypass: str | None = None
45
+ ignore_cert_errors: bool = False
46
+
47
+ @property
48
+ def requires_authentication(self) -> bool:
49
+ """Returns True if proxy requires authentication."""
50
+ return self.username is not None and self.password is not None
51
+
52
+ def validate(self) -> None:
53
+ """Validates the proxy configuration.
54
+
55
+ Raises:
56
+ ValueError: If configuration is invalid.
57
+ """
58
+ if not self.server:
59
+ raise ValueError("Proxy server cannot be empty")
60
+
61
+ # Validate that if one credential is provided, both are provided
62
+ if (self.username is None) != (self.password is None):
63
+ raise ValueError(
64
+ "Both username and password must be provided for proxy authentication, "
65
+ "or neither should be provided"
66
+ )
67
+
68
+
25
69
  @dataclass
26
70
  class BrowserConfig:
27
71
  executable_path: str = field(default_factory=_default_executable_path)
@@ -32,6 +76,7 @@ class BrowserConfig:
32
76
  initialization_url: str = "https://app.narada.ai/initialize"
33
77
  extension_id: str = "bhioaidlggjdkheaajakomifblpjmokn"
34
78
  interactive: bool = True
79
+ proxy: ProxyConfig | None = None
35
80
 
36
81
  @property
37
82
  def cdp_url(self) -> str:
narada/window.py CHANGED
@@ -4,12 +4,15 @@ import time
4
4
  from abc import ABC
5
5
  from http import HTTPStatus
6
6
  from pathlib import Path
7
- from typing import IO, Any, TypeVar, overload
7
+ from typing import IO, Any, Optional, TypeVar, overload
8
8
 
9
9
  import aiohttp
10
10
  from narada_core.actions.models import (
11
11
  AgenticSelectorAction,
12
12
  AgenticSelectorRequest,
13
+ AgenticSelectorResponse,
14
+ AgenticMouseActionRequest,
15
+ AgenticMouseAction,
13
16
  AgenticSelectors,
14
17
  AgentResponse,
15
18
  AgentUsage,
@@ -21,6 +24,7 @@ from narada_core.actions.models import (
21
24
  ReadGoogleSheetRequest,
22
25
  ReadGoogleSheetResponse,
23
26
  WriteGoogleSheetRequest,
27
+ RecordedClick,
24
28
  )
25
29
  from narada_core.errors import (
26
30
  NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE,
@@ -325,14 +329,47 @@ class BaseBrowserWindow(ABC):
325
329
  fallback_operator_query: str,
326
330
  # Larger default timeout because Operator can take a bit to run.
327
331
  timeout: int | None = 60,
328
- ) -> None:
332
+ ) -> AgenticSelectorResponse:
329
333
  """Performs an action on an element specified by the given selectors, falling back to using
330
334
  the Operator agent if the selectors fail to match a unique element.
331
335
  """
332
- return await self._run_extension_action(
336
+ response_model = (
337
+ AgenticSelectorResponse
338
+ if action["type"] in {"get_text", "get_property"}
339
+ else None
340
+ )
341
+ result = await self._run_extension_action(
333
342
  AgenticSelectorRequest(
334
343
  action=action,
335
344
  selectors=selectors,
345
+ response_model=response_model,
346
+ fallback_operator_query=fallback_operator_query,
347
+ ),
348
+ timeout=timeout,
349
+ )
350
+
351
+ if result is None:
352
+ return {"value": None}
353
+
354
+ return result
355
+
356
+ async def agentic_mouse_action(
357
+ self,
358
+ *,
359
+ action: AgenticMouseAction,
360
+ recorded_click: RecordedClick,
361
+ fallback_operator_query: str,
362
+ resize_window: bool = True,
363
+ timeout: int | None = 60,
364
+ ) -> None:
365
+ """Performs a mouse action at the specified click coordinates, falling back to using
366
+ the Operator agent if the click fails.
367
+ """
368
+ return await self._run_extension_action(
369
+ AgenticMouseActionRequest(
370
+ action=action,
371
+ recorded_click=recorded_click,
372
+ resize_window=resize_window,
336
373
  fallback_operator_query=fallback_operator_query,
337
374
  ),
338
375
  timeout=timeout,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narada
3
- Version: 0.1.28
3
+ Version: 0.1.29
4
4
  Summary: Python client SDK for Narada
5
5
  Project-URL: Homepage, https://github.com/NaradaAI/narada-python-sdk/narada
6
6
  Project-URL: Repository, https://github.com/NaradaAI/narada-python-sdk
@@ -9,7 +9,7 @@ Author-email: Narada <support@narada.ai>
9
9
  License-Expression: Apache-2.0
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: aiohttp>=3.12.13
12
- Requires-Dist: narada-core==0.0.6
12
+ Requires-Dist: narada-core==0.0.7
13
13
  Requires-Dist: playwright>=1.53.0
14
14
  Requires-Dist: rich>=14.0.0
15
15
  Requires-Dist: semver>=3.0.4
@@ -0,0 +1,10 @@
1
+ narada/__init__.py,sha256=2AnpZVQpkh4-onnvbgHX1Wlq1Fa8ya4vAzy2fbS0bCY,968
2
+ narada/client.py,sha256=_nTRHkiYt7lmUgO0HibVagrPXf523LCbWx8JmuVGgoo,21622
3
+ narada/config.py,sha256=S0B8GNd-0td_69oKaPN60WAq_ODeYU7avE_KAxN5vCg,3052
4
+ narada/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ narada/utils.py,sha256=gdLwNMXPpRohDcIIe0cB3KhvZ8X1QfAlKVh1sXWeJmk,1284
6
+ narada/version.py,sha256=kwW6yy0_4Pf3kt888eeCG0VwBb2L2rCkrkpdZEC_3rA,193
7
+ narada/window.py,sha256=hWkL637Aj7ZoJNFkvg92lSHRBaK72FY2WcCoyxzXKyk,18928
8
+ narada-0.1.29.dist-info/METADATA,sha256=ZRJ6lY2UYwj-kMsCOL5peS1mjni9Kr0Y5ROOfK4uBPE,5146
9
+ narada-0.1.29.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ narada-0.1.29.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- narada/__init__.py,sha256=Jk941BV4iy70Ei2MmHkNXEOkZUHBa6Lij_jZZRfiyhg,936
2
- narada/client.py,sha256=r_aBtqVYEBsr5N9E79qzl8q498F6cnSyejqZWSbB4qU,16768
3
- narada/config.py,sha256=tvj16P_qAHOFB2O_VlrcizA8SrbmS_Nmkm2r7ltG-VM,1345
4
- narada/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- narada/utils.py,sha256=gdLwNMXPpRohDcIIe0cB3KhvZ8X1QfAlKVh1sXWeJmk,1284
6
- narada/version.py,sha256=kwW6yy0_4Pf3kt888eeCG0VwBb2L2rCkrkpdZEC_3rA,193
7
- narada/window.py,sha256=OGuEs778xjxHivzWlJIIHKZPqGd4Sn9_YVArt_8TYoU,17762
8
- narada-0.1.28.dist-info/METADATA,sha256=jVmIONyot5rg8HLvqMIpSIjj2h25ycOoTbQcnb_8TnQ,5146
9
- narada-0.1.28.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
- narada-0.1.28.dist-info/RECORD,,