Skip to content

Commit 7b6547f

Browse files
authored
fix: Port URL validation, client timeout and credential scoping fixes to v1 (#6802)
1 parent 80e56de commit 7b6547f

8 files changed

Lines changed: 604 additions & 8 deletions

File tree

src/google/adk/agents/llm_agent.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,14 @@ def validate_generate_content_config(
891891
raise ValueError(
892892
'Response schema must be set via LlmAgent.output_schema.'
893893
)
894+
if (
895+
generate_content_config.http_options
896+
and generate_content_config.http_options.base_url
897+
):
898+
raise ValueError(
899+
'Base URL is a transport setting and must be set on the model or'
900+
' its client, not via LlmAgent.generate_content_config.'
901+
)
894902
return generate_content_config
895903

896904
@override

src/google/adk/models/apigee_llm.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,28 @@
6363

6464
_REFUSAL_PREFIX = '[[REFUSAL]]: '
6565

66+
# Timeouts, in seconds, for the completions HTTP client. httpx applies no
67+
# timeout at all unless one is given, so a stalled proxy would otherwise hold
68+
# the connection and the streaming loop open indefinitely.
69+
_CONNECT_TIMEOUT_SECONDS = 30.0
70+
_REQUEST_TIMEOUT_SECONDS = 600.0
71+
72+
73+
def _httpx_timeout(timeout_seconds: Optional[float] = None) -> httpx.Timeout:
74+
"""Returns the httpx timeout budget for a completions request.
75+
76+
A bare float would spend the caller's whole budget on the connect phase too,
77+
so the connect budget is always kept short enough to fail fast on an
78+
unreachable proxy.
79+
80+
Args:
81+
timeout_seconds: The total budget for the request, or None for the default.
82+
"""
83+
return httpx.Timeout(
84+
_REQUEST_TIMEOUT_SECONDS if timeout_seconds is None else timeout_seconds,
85+
connect=_CONNECT_TIMEOUT_SECONDS,
86+
)
87+
6688

6789
class ApigeeLlm(Gemini):
6890
"""A BaseLlm implementation for calling Apigee proxy.
@@ -427,8 +449,8 @@ def _client(self) -> httpx.AsyncClient:
427449
client = httpx.AsyncClient(
428450
base_url=self._base_url,
429451
headers=self._headers,
430-
timeout=None,
431-
follow_redirects=True,
452+
timeout=_httpx_timeout(),
453+
follow_redirects=False,
432454
)
433455
atexit.register(self._cleanup_client, client)
434456
return client
@@ -519,6 +541,7 @@ async def generate_content_async(
519541
) -> AsyncGenerator[LlmResponse, None]:
520542
"""Generates content using the OpenAI-compatible HTTP API."""
521543
payload = self._construct_payload(llm_request, stream)
544+
timeout = self._get_request_timeout_seconds(llm_request)
522545
headers = self._headers.copy()
523546
headers['Content-Type'] = 'application/json'
524547

@@ -530,26 +553,50 @@ async def generate_content_async(
530553
url = f"{url.rstrip('/')}/chat/completions"
531554

532555
if stream:
533-
async for stream_res in self._handle_streaming(url, payload, headers):
556+
async for stream_res in self._handle_streaming(
557+
url, payload, headers, timeout=timeout
558+
):
534559
yield stream_res
535560
else:
536-
response = await self._httpx_post_with_retry(url, payload, headers)
561+
response = await self._httpx_post_with_retry(
562+
url, payload, headers, timeout=timeout
563+
)
537564
data = response.json()
538565
yield self._parse_response(data)
539566

567+
@staticmethod
568+
def _get_request_timeout_seconds(llm_request: LlmRequest) -> float | None:
569+
"""Returns the request timeout converted from milliseconds to seconds."""
570+
if not llm_request.config or not llm_request.config.http_options:
571+
return None
572+
timeout_ms = llm_request.config.http_options.timeout
573+
return timeout_ms / 1000 if timeout_ms is not None else None
574+
540575
async def _httpx_post_with_retry(
541-
self, url: str, payload: dict[str, Any], headers: dict[str, str]
576+
self,
577+
url: str,
578+
payload: dict[str, Any],
579+
headers: dict[str, str],
580+
*,
581+
timeout: float | None,
542582
) -> httpx.Response:
543583
"""Sends a POST request and handles retries."""
544584
retry_kwargs = self._get_retry_kwargs()
545585
async for attempt in tenacity.AsyncRetrying(**retry_kwargs):
546586
with attempt:
547-
response = await self._client.post(url, json=payload, headers=headers)
587+
response = await self._client.post(
588+
url, json=payload, headers=headers, timeout=_httpx_timeout(timeout)
589+
)
548590
response.raise_for_status()
549591
return response
550592

551593
async def _handle_streaming(
552-
self, url: str, payload: dict[str, Any], headers: dict[str, str]
594+
self,
595+
url: str,
596+
payload: dict[str, Any],
597+
headers: dict[str, str],
598+
*,
599+
timeout: float | None,
553600
) -> AsyncGenerator[LlmResponse, None]:
554601
"""Handles streaming response from OpenAI-compatible API."""
555602
accumulator = ChatCompletionsResponseHandler()
@@ -558,6 +605,7 @@ async def _handle_streaming(
558605
url,
559606
json=payload,
560607
headers=headers,
608+
timeout=_httpx_timeout(timeout),
561609
) as resp:
562610
resp.raise_for_status()
563611
async for line in resp.aiter_lines():

src/google/adk/tools/computer_use/computer_use_toolset.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,17 @@
3333
from ..base_toolset import BaseToolset
3434
from ..tool_context import ToolContext
3535
from .base_computer import BaseComputer
36+
from .base_computer import ComputerState
3637
from .computer_use_tool import ComputerUseTool
3738

3839
# Methods that should be excluded when creating tools from BaseComputer methods
3940
EXCLUDED_METHODS = {"screen_size", "environment", "close", "prepare"}
4041

42+
_URL_REFUSED_ERROR = (
43+
"navigate refused: url must be http(s) and must not target a private or"
44+
" link-local address."
45+
)
46+
4147
logger = logging.getLogger("google_adk." + __name__)
4248

4349

@@ -49,10 +55,22 @@ def __init__(
4955
*,
5056
computer: BaseComputer,
5157
excluded_predefined_functions: Optional[list[str]] = None,
58+
allow_private_network_access: bool = False,
5259
):
60+
"""Initializes the ComputerUseToolset.
61+
62+
Args:
63+
computer: The computer environment to expose as tools.
64+
excluded_predefined_functions: Names of BaseComputer methods that should
65+
not be exposed as tools.
66+
allow_private_network_access: By default `navigate` refuses urls whose
67+
host is not publicly routable. Set this to True when the agent is
68+
meant to drive the browser against localhost or an internal host.
69+
"""
5370
super().__init__()
5471
self._computer = computer
5572
self._excluded_predefined_functions = excluded_predefined_functions
73+
self._allow_private_network_access = allow_private_network_access
5674
self._initialized = False
5775
self._tools = None
5876

@@ -107,6 +125,42 @@ async def wrapper(
107125

108126
return wrapper
109127

128+
def _wrap_navigate_with_url_validation(
129+
self, navigate_method: Callable[..., Any]
130+
) -> Callable[..., Any]:
131+
"""Checks a model-supplied url before `navigate` hands it to the browser."""
132+
133+
@functools.wraps(navigate_method)
134+
async def wrapper(url: str) -> Any:
135+
# Deferred to keep `requests` off the computer-use import path.
136+
from ..load_web_page import _is_blocked_hostname
137+
from ..load_web_page import _parse_request_target
138+
from ..load_web_page import _resolve_direct_addresses
139+
140+
try:
141+
if not isinstance(url, str):
142+
raise ValueError("url is not a string")
143+
target = _parse_request_target(url)
144+
# A browser ends the authority at "\" but urlparse does not: in
145+
# `http://169.254.169.254\@example.com/` the host is example.com here
146+
# and 169.254.169.254 in Chrome, so refuse instead of checking it.
147+
if "\\" in target.parsed_url.netloc:
148+
raise ValueError("backslash in hostname")
149+
if not self._allow_private_network_access:
150+
if _is_blocked_hostname(target.hostname):
151+
raise ValueError("hostname is blocked")
152+
# getaddrinfo blocks, so keep it off the event loop.
153+
await asyncio.to_thread(_resolve_direct_addresses, target.hostname)
154+
except ValueError:
155+
logger.warning("Refusing navigate(): url failed safety validation.")
156+
# The computer-use model rejects a function response with no url,
157+
# so report the page the browser is currently on.
158+
state: ComputerState = await self._computer.current_state()
159+
return {"error": _URL_REFUSED_ERROR, "url": state.url}
160+
return await navigate_method(url)
161+
162+
return wrapper
163+
110164
@staticmethod
111165
async def adapt_computer_use_tool(
112166
method_name: str,
@@ -217,6 +271,11 @@ async def get_tools(
217271
if attr is not None and callable(attr):
218272
# Get the corresponding method from the concrete instance
219273
instance_method = getattr(self._computer, method_name)
274+
if method_name == "navigate":
275+
# Check the url the model supplied before it reaches the browser.
276+
instance_method = self._wrap_navigate_with_url_validation(
277+
instance_method
278+
)
220279
# Wrap with state binding so session_state is set before each call
221280
wrapped_method = self._wrap_method_with_state_binding(instance_method)
222281
computer_methods.append(wrapped_method)

src/google/adk/tools/load_web_page.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,42 @@ def _is_blocked_hostname(hostname: str) -> bool:
156156
)
157157

158158

159+
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
160+
161+
162+
def _embedded_ipv4(address: _ResolvedAddress) -> ipaddress.IPv4Address | None:
163+
"""Returns the IPv4 address embedded in an IPv6 address, if any.
164+
165+
``is_global`` on the outer IPv6 address does not reflect the reachability of
166+
the embedded IPv4 target for IPv4-mapped (``::ffff:a.b.c.d``), IPv4-compatible
167+
(``::a.b.c.d``), 6to4 (``2002::/16``) and NAT64 (``64:ff9b::/96``) addresses.
168+
For example ``64:ff9b::169.254.169.254`` is reported as global but, on a
169+
network with NAT64, routes to the internal ``169.254.169.254`` metadata
170+
endpoint. Returning the embedded IPv4 lets the caller vet it directly.
171+
"""
172+
if not isinstance(address, ipaddress.IPv6Address):
173+
return None
174+
if address.ipv4_mapped is not None:
175+
return address.ipv4_mapped
176+
if address.sixtofour is not None:
177+
return address.sixtofour
178+
if address in _NAT64_WELL_KNOWN_PREFIX:
179+
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
180+
# IPv4-compatible ``::a.b.c.d`` (deprecated): top 96 bits zero, low 32 bits a
181+
# non-trivial IPv4 (excluding ``::`` and ``::1``).
182+
packed = int(address)
183+
if packed >> 32 == 0 and (packed & 0xFFFFFFFF) not in (0, 1):
184+
return ipaddress.IPv4Address(packed & 0xFFFFFFFF)
185+
return None
186+
187+
159188
def _is_blocked_address(address: _ResolvedAddress) -> bool:
160-
return not address.is_global
189+
if not address.is_global:
190+
return True
191+
# Reject IPv6 addresses that embed a non-global IPv4 target (NAT64,
192+
# IPv4-compatible, etc.), which `is_global` alone does not catch.
193+
embedded = _embedded_ipv4(address)
194+
return embedded is not None and not embedded.is_global
161195

162196

163197
def _resolve_host_addresses(hostname: str) -> tuple[_ResolvedAddress, ...]:

tests/unittests/agents/test_llm_agent_fields.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,31 @@ class Schema(BaseModel):
300300
)
301301

302302

303+
def test_validate_generate_content_config_http_options_base_url_throw():
304+
"""Tests that a transport base URL cannot be set directly in config."""
305+
with pytest.raises(ValueError):
306+
_ = LlmAgent(
307+
name='test_agent',
308+
generate_content_config=types.GenerateContentConfig(
309+
http_options=types.HttpOptions(base_url='http://example.invalid')
310+
),
311+
)
312+
313+
314+
def test_validate_generate_content_config_http_options_allowed():
315+
"""Tests that request-time http options remain settable in config."""
316+
extra_body = {'tool_config': {'function_calling_config': {'mode': 'AUTO'}}}
317+
agent = LlmAgent(
318+
name='test_agent',
319+
generate_content_config=types.GenerateContentConfig(
320+
http_options=types.HttpOptions(timeout=1000, extra_body=extra_body)
321+
),
322+
)
323+
324+
assert agent.generate_content_config.http_options.timeout == 1000
325+
assert agent.generate_content_config.http_options.extra_body == extra_body
326+
327+
303328
def test_allow_transfer_by_default():
304329
sub_agent = LlmAgent(name='sub_agent')
305330
agent = LlmAgent(name='test_agent', sub_agents=[sub_agent])

0 commit comments

Comments
 (0)