narada 0.1.28__py3-none-any.whl → 0.1.30__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,16 @@ 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
+ ActionTraceItem,
11
12
  AgenticSelectorAction,
12
13
  AgenticSelectorRequest,
14
+ AgenticSelectorResponse,
15
+ AgenticMouseActionRequest,
16
+ AgenticMouseAction,
13
17
  AgenticSelectors,
14
18
  AgentResponse,
15
19
  AgentUsage,
@@ -21,6 +25,7 @@ from narada_core.actions.models import (
21
25
  ReadGoogleSheetRequest,
22
26
  ReadGoogleSheetResponse,
23
27
  WriteGoogleSheetRequest,
28
+ RecordedClick,
24
29
  )
25
30
  from narada_core.errors import (
26
31
  NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE,
@@ -121,6 +126,7 @@ class BaseBrowserWindow(ABC):
121
126
  variables: dict[str, str] | None = None,
122
127
  callback_url: str | None = None,
123
128
  callback_secret: str | None = None,
129
+ callback_headers: dict[str, Any] | None = None,
124
130
  timeout: int = 1000,
125
131
  ) -> Response[None]: ...
126
132
 
@@ -142,6 +148,7 @@ class BaseBrowserWindow(ABC):
142
148
  variables: dict[str, str] | None = None,
143
149
  callback_url: str | None = None,
144
150
  callback_secret: str | None = None,
151
+ callback_headers: dict[str, Any] | None = None,
145
152
  timeout: int = 1000,
146
153
  ) -> Response[_StructuredOutput]: ...
147
154
 
@@ -162,6 +169,7 @@ class BaseBrowserWindow(ABC):
162
169
  variables: dict[str, str] | None = None,
163
170
  callback_url: str | None = None,
164
171
  callback_secret: str | None = None,
172
+ callback_headers: dict[str, Any] | None = None,
165
173
  timeout: int = 1000,
166
174
  ) -> Response:
167
175
  """Low-level API for invoking an agent in the Narada extension side panel chat.
@@ -205,6 +213,8 @@ class BaseBrowserWindow(ABC):
205
213
  body["callbackUrl"] = callback_url
206
214
  if callback_secret is not None:
207
215
  body["callbackSecret"] = callback_secret
216
+ if callback_headers is not None:
217
+ body["callbackHeaders"] = callback_headers
208
218
 
209
219
  try:
210
220
  async with aiohttp.ClientSession() as session:
@@ -309,12 +319,20 @@ class BaseBrowserWindow(ABC):
309
319
  response_content = remote_dispatch_response["response"]
310
320
  assert response_content is not None
311
321
 
322
+ action_trace_raw = response_content.get("actionTrace")
323
+ action_trace = (
324
+ [ActionTraceItem.model_validate(item) for item in action_trace_raw]
325
+ if action_trace_raw is not None
326
+ else None
327
+ )
328
+
312
329
  return AgentResponse(
313
330
  request_id=remote_dispatch_response["requestId"],
314
331
  status=remote_dispatch_response["status"],
315
332
  text=response_content["text"],
316
333
  structured_output=response_content.get("structuredOutput"),
317
334
  usage=AgentUsage.model_validate(remote_dispatch_response["usage"]),
335
+ action_trace=action_trace,
318
336
  )
319
337
 
320
338
  async def agentic_selector(
@@ -325,14 +343,47 @@ class BaseBrowserWindow(ABC):
325
343
  fallback_operator_query: str,
326
344
  # Larger default timeout because Operator can take a bit to run.
327
345
  timeout: int | None = 60,
328
- ) -> None:
346
+ ) -> AgenticSelectorResponse:
329
347
  """Performs an action on an element specified by the given selectors, falling back to using
330
348
  the Operator agent if the selectors fail to match a unique element.
331
349
  """
332
- return await self._run_extension_action(
350
+ response_model = (
351
+ AgenticSelectorResponse
352
+ if action["type"] in {"get_text", "get_property"}
353
+ else None
354
+ )
355
+ result = await self._run_extension_action(
333
356
  AgenticSelectorRequest(
334
357
  action=action,
335
358
  selectors=selectors,
359
+ response_model=response_model,
360
+ fallback_operator_query=fallback_operator_query,
361
+ ),
362
+ timeout=timeout,
363
+ )
364
+
365
+ if result is None:
366
+ return {"value": None}
367
+
368
+ return result
369
+
370
+ async def agentic_mouse_action(
371
+ self,
372
+ *,
373
+ action: AgenticMouseAction,
374
+ recorded_click: RecordedClick,
375
+ fallback_operator_query: str,
376
+ resize_window: bool = True,
377
+ timeout: int | None = 60,
378
+ ) -> None:
379
+ """Performs a mouse action at the specified click coordinates, falling back to using
380
+ the Operator agent if the click fails.
381
+ """
382
+ return await self._run_extension_action(
383
+ AgenticMouseActionRequest(
384
+ action=action,
385
+ recorded_click=recorded_click,
386
+ resize_window=resize_window,
336
387
  fallback_operator_query=fallback_operator_query,
337
388
  ),
338
389
  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.30
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.8
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=f4Sy_ouKLF4z_KXupoUi0WaAqpEOPp25lKHP5rY_KOA,19497
8
+ narada-0.1.30.dist-info/METADATA,sha256=xCsQXj57ZyUGjUFT0NFZUNQ3MwxFJCoy7mNKHVdLEjs,5146
9
+ narada-0.1.30.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ narada-0.1.30.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,,