Skip to content

Commit b27750b

Browse files
authored
Merge pull request #191 from SentienceAPI/permissions
Handle Chrome permissions bubble
2 parents 133aa6c + 7d828e3 commit b27750b

File tree

8 files changed

+258
-2
lines changed

8 files changed

+258
-2
lines changed

sentience/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
# Agent Layer (Phase 1 & 2)
5757
from .base_agent import BaseAgent
5858
from .browser import AsyncSentienceBrowser, SentienceBrowser
59+
from .permissions import PermissionPolicy
5960
from .captcha import CaptchaContext, CaptchaHandlingError, CaptchaOptions, CaptchaResolution
6061
from .captcha_strategies import ExternalSolver, HumanHandoffSolver, VisionSolver
6162

sentience/agent_runtime.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,21 @@ def capabilities(self) -> BackendCapabilities:
385385
getattr(getattr(backend, "_page", None), "keyboard", None)
386386
)
387387
has_downloads = bool(getattr(backend, "downloads", None))
388+
has_permissions = False
389+
try:
390+
context = None
391+
legacy_browser = getattr(self, "_legacy_browser", None)
392+
if legacy_browser is not None:
393+
context = getattr(legacy_browser, "context", None)
394+
if context is None:
395+
page = getattr(backend, "_page", None) or getattr(backend, "page", None)
396+
context = getattr(page, "context", None) if page is not None else None
397+
if context is not None:
398+
has_permissions = bool(
399+
hasattr(context, "clear_permissions") and hasattr(context, "grant_permissions")
400+
)
401+
except Exception:
402+
has_permissions = False
388403
has_files = False
389404
if self.tool_registry is not None:
390405
try:
@@ -397,6 +412,7 @@ def capabilities(self) -> BackendCapabilities:
397412
downloads=has_downloads,
398413
filesystem_tools=has_files,
399414
keyboard=bool(has_keyboard or has_eval),
415+
permissions=has_permissions,
400416
)
401417

402418
def can(self, capability: str) -> bool:

sentience/browser.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from sentience._extension_loader import find_extension_path
2323
from sentience.constants import SENTIENCE_API_URL
2424
from sentience.models import ProxyConfig, StorageState, Viewport
25+
from sentience.permissions import PermissionPolicy
2526

2627
logger = logging.getLogger(__name__)
2728

@@ -97,6 +98,7 @@ def __init__(
9798
allowed_domains: list[str] | None = None,
9899
prohibited_domains: list[str] | None = None,
99100
keep_alive: bool = False,
101+
permission_policy: PermissionPolicy | dict | None = None,
100102
):
101103
"""
102104
Initialize Sentience browser
@@ -134,6 +136,7 @@ def __init__(
134136
Viewport(width=1920, height=1080) (Full HD)
135137
{"width": 1280, "height": 800} (dict also supported)
136138
If None, defaults to Viewport(width=1280, height=800).
139+
permission_policy: Optional permission policy to apply on context creation.
137140
"""
138141
self.api_key = api_key
139142
# Only set api_url if api_key is provided, otherwise None (free tier)
@@ -165,6 +168,7 @@ def __init__(
165168
self.allowed_domains = allowed_domains or []
166169
self.prohibited_domains = prohibited_domains or []
167170
self.keep_alive = keep_alive
171+
self.permission_policy = self._coerce_permission_policy(permission_policy)
168172

169173
# Viewport configuration - convert dict to Viewport if needed
170174
if viewport is None:
@@ -231,6 +235,28 @@ def _parse_proxy(self, proxy_string: str) -> ProxyConfig | None:
231235
)
232236
return None
233237

238+
def _coerce_permission_policy(
239+
self, policy: PermissionPolicy | dict | None
240+
) -> PermissionPolicy | None:
241+
if policy is None:
242+
return None
243+
if isinstance(policy, PermissionPolicy):
244+
return policy
245+
if isinstance(policy, dict):
246+
return PermissionPolicy(**policy)
247+
raise TypeError("permission_policy must be PermissionPolicy, dict, or None")
248+
249+
def apply_permission_policy(self, context: BrowserContext) -> None:
250+
policy = self.permission_policy
251+
if policy is None:
252+
return
253+
if policy.default in ("clear", "deny"):
254+
context.clear_permissions()
255+
if policy.geolocation:
256+
context.set_geolocation(policy.geolocation)
257+
if policy.auto_grant:
258+
context.grant_permissions(policy.auto_grant, origin=policy.origin)
259+
234260
def start(self) -> None:
235261
"""Launch browser with extension loaded"""
236262
# Get extension source path using shared utility
@@ -338,6 +364,9 @@ def start(self) -> None:
338364
# headless mode via the --headless=new arg above. This is a Playwright workaround.
339365
self.context = self.playwright.chromium.launch_persistent_context(**launch_params)
340366

367+
if self.context is not None:
368+
self.apply_permission_policy(self.context)
369+
341370
self.page = self.context.pages[0] if self.context.pages else self.context.new_page()
342371

343372
# Inject storage state if provided (must be after context creation)
@@ -712,6 +741,7 @@ def __init__(
712741
allowed_domains: list[str] | None = None,
713742
prohibited_domains: list[str] | None = None,
714743
keep_alive: bool = False,
744+
permission_policy: PermissionPolicy | dict | None = None,
715745
):
716746
"""
717747
Initialize Async Sentience browser
@@ -740,6 +770,7 @@ def __init__(
740770
this specific browser binary instead of Playwright's managed browser.
741771
Useful to guarantee Chromium (not Chrome for Testing) on macOS.
742772
Example: "/path/to/playwright/chromium-1234/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
773+
permission_policy: Optional permission policy to apply on context creation.
743774
"""
744775
self.api_key = api_key
745776
# Only set api_url if api_key is provided, otherwise None (free tier)
@@ -770,6 +801,7 @@ def __init__(
770801
self.allowed_domains = allowed_domains or []
771802
self.prohibited_domains = prohibited_domains or []
772803
self.keep_alive = keep_alive
804+
self.permission_policy = self._coerce_permission_policy(permission_policy)
773805

774806
# Viewport configuration - convert dict to Viewport if needed
775807
if viewport is None:
@@ -836,6 +868,28 @@ def _parse_proxy(self, proxy_string: str) -> ProxyConfig | None:
836868
)
837869
return None
838870

871+
def _coerce_permission_policy(
872+
self, policy: PermissionPolicy | dict | None
873+
) -> PermissionPolicy | None:
874+
if policy is None:
875+
return None
876+
if isinstance(policy, PermissionPolicy):
877+
return policy
878+
if isinstance(policy, dict):
879+
return PermissionPolicy(**policy)
880+
raise TypeError("permission_policy must be PermissionPolicy, dict, or None")
881+
882+
async def apply_permission_policy(self, context: AsyncBrowserContext) -> None:
883+
policy = self.permission_policy
884+
if policy is None:
885+
return
886+
if policy.default in ("clear", "deny"):
887+
await context.clear_permissions()
888+
if policy.geolocation:
889+
await context.set_geolocation(policy.geolocation)
890+
if policy.auto_grant:
891+
await context.grant_permissions(policy.auto_grant, origin=policy.origin)
892+
839893
async def start(self) -> None:
840894
"""Launch browser with extension loaded (async)"""
841895
# Get extension source path using shared utility
@@ -939,6 +993,9 @@ async def start(self) -> None:
939993
# Launch persistent context
940994
self.context = await self.playwright.chromium.launch_persistent_context(**launch_params)
941995

996+
if self.context is not None:
997+
await self.apply_permission_policy(self.context)
998+
942999
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
9431000

9441001
# Inject storage state if provided

sentience/permissions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import Literal
5+
6+
PermissionDefault = Literal["clear", "deny", "grant"]
7+
8+
9+
@dataclass
10+
class PermissionPolicy:
11+
"""
12+
Browser permission handling policy applied on context creation.
13+
"""
14+
15+
default: PermissionDefault = "clear"
16+
auto_grant: list[str] = field(default_factory=list)
17+
geolocation: dict | None = None
18+
origin: str | None = None

sentience/tools/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class BackendCapabilities(BaseModel):
1818
downloads: bool = False
1919
filesystem_tools: bool = False
2020
keyboard: bool = False
21+
permissions: bool = False
2122

2223

2324
class UnsupportedCapabilityError(RuntimeError):

sentience/tools/defaults.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from ..agent_runtime import AgentRuntime
88
from ..backends import actions as backend_actions
99
from ..models import ActionResult, BBox, EvaluateJsRequest, Snapshot
10-
from .context import ToolContext
10+
from .context import ToolContext, UnsupportedCapabilityError
1111
from .registry import ToolRegistry
1212

1313

@@ -54,6 +54,21 @@ class EvaluateJsToolInput(BaseModel):
5454
truncate: bool = Field(True, description="Truncate output when too long.")
5555

5656

57+
class GrantPermissionsInput(BaseModel):
58+
permissions: list[str] = Field(..., min_length=1, description="Permissions to grant.")
59+
origin: str | None = Field(None, description="Optional origin to apply permissions.")
60+
61+
62+
class ClearPermissionsInput(BaseModel):
63+
pass
64+
65+
66+
class SetGeolocationInput(BaseModel):
67+
latitude: float = Field(..., description="Latitude in decimal degrees.")
68+
longitude: float = Field(..., description="Longitude in decimal degrees.")
69+
accuracy: float | None = Field(None, description="Optional accuracy in meters.")
70+
71+
5772
def register_default_tools(
5873
registry: ToolRegistry, runtime: ToolContext | "AgentRuntime" | None = None
5974
) -> ToolRegistry:
@@ -68,6 +83,17 @@ def _get_runtime(ctx: ToolContext | None):
6883
return runtime
6984
raise RuntimeError("ToolContext with runtime is required")
7085

86+
def _get_permission_context(runtime_ref):
87+
legacy_browser = getattr(runtime_ref, "_legacy_browser", None)
88+
if legacy_browser is not None:
89+
context = getattr(legacy_browser, "context", None)
90+
if context is not None:
91+
return context
92+
backend = getattr(runtime_ref, "backend", None)
93+
page = getattr(backend, "_page", None) or getattr(backend, "page", None)
94+
context = getattr(page, "context", None) if page is not None else None
95+
return context
96+
7197
@registry.tool(
7298
name="snapshot_state",
7399
input_model=SnapshotToolInput,
@@ -229,4 +255,64 @@ async def evaluate_js_tool(ctx, params: EvaluateJsToolInput) -> ActionResult:
229255
outcome="dom_updated",
230256
)
231257

258+
@registry.tool(
259+
name="grant_permissions",
260+
input_model=GrantPermissionsInput,
261+
output_model=ActionResult,
262+
description="Grant browser permissions for the current context.",
263+
)
264+
async def grant_permissions_tool(ctx, params: GrantPermissionsInput) -> ActionResult:
265+
runtime_ref = _get_runtime(ctx)
266+
if ctx is not None:
267+
ctx.require("permissions")
268+
elif not runtime_ref.can("permissions"):
269+
raise UnsupportedCapabilityError("permissions")
270+
context = _get_permission_context(runtime_ref)
271+
if context is None:
272+
raise RuntimeError("Permission context unavailable")
273+
await context.grant_permissions(params.permissions, origin=params.origin)
274+
return ActionResult(success=True, duration_ms=0, outcome="dom_updated")
275+
276+
@registry.tool(
277+
name="clear_permissions",
278+
input_model=ClearPermissionsInput,
279+
output_model=ActionResult,
280+
description="Clear browser permissions for the current context.",
281+
)
282+
async def clear_permissions_tool(ctx, _params: ClearPermissionsInput) -> ActionResult:
283+
runtime_ref = _get_runtime(ctx)
284+
if ctx is not None:
285+
ctx.require("permissions")
286+
elif not runtime_ref.can("permissions"):
287+
raise UnsupportedCapabilityError("permissions")
288+
context = _get_permission_context(runtime_ref)
289+
if context is None:
290+
raise RuntimeError("Permission context unavailable")
291+
await context.clear_permissions()
292+
return ActionResult(success=True, duration_ms=0, outcome="dom_updated")
293+
294+
@registry.tool(
295+
name="set_geolocation",
296+
input_model=SetGeolocationInput,
297+
output_model=ActionResult,
298+
description="Set geolocation for the current browser context.",
299+
)
300+
async def set_geolocation_tool(ctx, params: SetGeolocationInput) -> ActionResult:
301+
runtime_ref = _get_runtime(ctx)
302+
if ctx is not None:
303+
ctx.require("permissions")
304+
elif not runtime_ref.can("permissions"):
305+
raise UnsupportedCapabilityError("permissions")
306+
context = _get_permission_context(runtime_ref)
307+
if context is None:
308+
raise RuntimeError("Permission context unavailable")
309+
await context.set_geolocation(
310+
{
311+
"latitude": params.latitude,
312+
"longitude": params.longitude,
313+
"accuracy": params.accuracy,
314+
}
315+
)
316+
return ActionResult(success=True, duration_ms=0, outcome="dom_updated")
317+
232318
return registry
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import pytest
2+
3+
from sentience import AsyncSentienceBrowser, SentienceBrowser
4+
from sentience.permissions import PermissionPolicy
5+
6+
7+
class SyncContextStub:
8+
def __init__(self) -> None:
9+
self.calls: list[tuple | str] = []
10+
11+
def clear_permissions(self) -> None:
12+
self.calls.append("clear")
13+
14+
def set_geolocation(self, geolocation: dict) -> None:
15+
self.calls.append(("geolocation", geolocation))
16+
17+
def grant_permissions(self, permissions: list[str], origin: str | None = None) -> None:
18+
self.calls.append(("grant", permissions, origin))
19+
20+
21+
class AsyncContextStub:
22+
def __init__(self) -> None:
23+
self.calls: list[tuple | str] = []
24+
25+
async def clear_permissions(self) -> None:
26+
self.calls.append("clear")
27+
28+
async def set_geolocation(self, geolocation: dict) -> None:
29+
self.calls.append(("geolocation", geolocation))
30+
31+
async def grant_permissions(self, permissions: list[str], origin: str | None = None) -> None:
32+
self.calls.append(("grant", permissions, origin))
33+
34+
35+
def test_apply_permission_policy_sync() -> None:
36+
policy = PermissionPolicy(
37+
default="clear",
38+
auto_grant=["geolocation"],
39+
geolocation={"latitude": 37.77, "longitude": -122.41},
40+
origin="https://example.com",
41+
)
42+
browser = SentienceBrowser(permission_policy=policy)
43+
context = SyncContextStub()
44+
browser.apply_permission_policy(context)
45+
assert context.calls == [
46+
"clear",
47+
("geolocation", {"latitude": 37.77, "longitude": -122.41}),
48+
("grant", ["geolocation"], "https://example.com"),
49+
]
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_apply_permission_policy_async() -> None:
54+
policy = PermissionPolicy(
55+
default="clear",
56+
auto_grant=["notifications"],
57+
geolocation={"latitude": 40.71, "longitude": -74.0, "accuracy": 10},
58+
)
59+
browser = AsyncSentienceBrowser(permission_policy=policy)
60+
context = AsyncContextStub()
61+
await browser.apply_permission_policy(context)
62+
assert context.calls == [
63+
"clear",
64+
("geolocation", {"latitude": 40.71, "longitude": -74.0, "accuracy": 10}),
65+
("grant", ["notifications"], None),
66+
]

0 commit comments

Comments
 (0)