diff --git a/.github/workflows/release-update-adk-web.yaml b/.github/workflows/release-update-adk-web.yaml index 37d8d45f07f..b0796652293 100644 --- a/.github/workflows/release-update-adk-web.yaml +++ b/.github/workflows/release-update-adk-web.yaml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Fetch and unzip frontend assets run: | diff --git a/contributing/samples/integrations/gepa/rater_lib.py b/contributing/samples/integrations/gepa/rater_lib.py index 50bbdc229d2..67c46da71c8 100644 --- a/contributing/samples/integrations/gepa/rater_lib.py +++ b/contributing/samples/integrations/gepa/rater_lib.py @@ -167,7 +167,7 @@ def __call__(self, messages: list[dict[str, Any]]) -> dict[str, Any]: Returns: A dictionary containing rating information including score. """ - env = jinja2.Environment() + env = jinja2.Environment(autoescape=True) env.globals['user_input'] = ( messages[0].get('parts', [{}])[0].get('text', '') if messages else '' ) diff --git a/contributing/samples/integrations/gepa/rater_lib_test.py b/contributing/samples/integrations/gepa/rater_lib_test.py new file mode 100644 index 00000000000..54a7db8a93e --- /dev/null +++ b/contributing/samples/integrations/gepa/rater_lib_test.py @@ -0,0 +1,79 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import sys +from unittest import mock + +# Mock the retry module before importing rater_lib +mock_retry_module = mock.MagicMock() + + +def mock_retry_decorator(*args, **kwargs): + def decorator(func): + return func + + return decorator + + +mock_retry_module.retry = mock_retry_decorator +sys.modules["retry"] = mock_retry_module + +from gepa import rater_lib + + +def test_rater_escapes_html_inputs_to_prevent_xss(): + """Rater escapes HTML tags in user and model inputs to prevent XSS. + + Setup: Mock genai.Client to return a dummy rating response. + Act: Call Rater with messages containing HTML tags. + Assert: Verify that the rendered prompt template contains escaped HTML. + """ + # Arrange + with mock.patch("google.genai.Client") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_generate = mock_client.models.generate_content + mock_generate.return_value.text = ( + "Property: The agent fulfilled the user's primary request.\n" + "Evidence: mock evidence\n" + "Rationale: mock rationale\n" + "Verdict: yes" + ) + + template_path = ( + "contributing/samples/integrations/gepa/rubric_validation_template.txt" + ) + rater = rater_lib.Rater( + tool_declarations="[]", validation_template_path=template_path + ) + + messages = [ + { + "role": "user", + "parts": [{"text": ""}], + }, + { + "role": "model", + "parts": [{"text": "Hello "}], + }, + ] + + # Act + rater(messages) + + # Assert + assert mock_generate.called + call_args = mock_generate.call_args + contents = call_args.kwargs["contents"] + + assert "<script>alert('XSS')</script>" in contents + assert "Hello <img src=x onerror=alert(1)>" in contents diff --git a/contributing/samples/integrations/gepa/rubric_validation_template.txt b/contributing/samples/integrations/gepa/rubric_validation_template.txt index eeab5af849c..74db111ab02 100644 --- a/contributing/samples/integrations/gepa/rubric_validation_template.txt +++ b/contributing/samples/integrations/gepa/rubric_validation_template.txt @@ -155,12 +155,12 @@ Verdict: no - {{user_input}} + {{user_input|e}} -{{model_response}} +{{model_response|e}} diff --git a/contributing/samples/managed_agent/basic/README.md b/contributing/samples/managed_agent/basic/README.md new file mode 100644 index 00000000000..d50095e2c4e --- /dev/null +++ b/contributing/samples/managed_agent/basic/README.md @@ -0,0 +1,47 @@ +# Managed Agent + +## Overview + +This sample runs a `ManagedAgent` configured with the built-in `google_search` +tool. Given an open-ended request, the server-side harness autonomously issues +many searches and synthesizes the result in a single turn. + +## Sample Inputs + +- `Compare the current flagship smartphones from Apple, Samsung, and Google. For each, find its launch price, display size, and main rear camera resolution, then recommend the best value for someone who mostly takes photos.` + + The showcase input. A single question the harness answers by fanning out into a + dozen-odd searches. It does broad discovery first, then targeted per-model spec + and price lookups, self-correcting when it hits a stale model, before composing + a comparison table and a reasoned recommendation. + +- `Which of those would you pick for shooting video instead?` + + A follow-up turn that reuses the recovered remote sandbox and the previous + interaction, continuing the same research thread. This demonstrates multi-turn + chaining. + +## Graph + +```mermaid +graph LR + User -->|message| ManagedAgent + ManagedAgent -->|interactions.create| ManagedAgentsAPI + ManagedAgentsAPI -->|server-side research loop: google_search ×N| ManagedAgentsAPI + ManagedAgentsAPI -->|streamed events| ManagedAgent + ManagedAgent -->|answer| User +``` + +## How To + +- **Create the agent**: instantiate `ManagedAgent` with an `agent_id`, an + `environment` spec, and a list of server-side `tools`. No `model` is set; the + model is part of the managed agent on the server. +- **Provision a sandbox**: `environment={'type': 'remote'}` requests a fresh + remote sandbox. The resulting environment id is stored on emitted events, so + subsequent turns automatically recover and reuse it. +- **Multi-turn chaining**: the agent recovers the `previous_interaction_id` from + the session events, so follow-up turns continue the same interaction without + any extra wiring. +- **Drive it**: a `ManagedAgent` is a `BaseAgent`, so a standard `Runner` runs + it just like any other agent. diff --git a/contributing/samples/managed_agent/basic/__init__.py b/contributing/samples/managed_agent/basic/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/managed_agent/basic/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/managed_agent/basic/agent.py b/contributing/samples/managed_agent/basic/agent.py new file mode 100644 index 00000000000..c6c7b3bc34a --- /dev/null +++ b/contributing/samples/managed_agent/basic/agent.py @@ -0,0 +1,49 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A ManagedAgent backed by the Managed Agents API (interactions.create). + +``ManagedAgent`` calls the Managed Agents API directly from its run loop instead +of running a local model loop. It currently supports server-side tools only +(ADK built-in tools and raw ``google.genai.types.Tool`` configs); here we wire +up ``google_search``, which runs entirely on the server. + +A fresh remote sandbox is provisioned via ``environment={'type': 'remote'}``; +the environment id is recovered from prior events so multi-turn conversations +reuse the same sandbox. + +Run with ``adk web`` / ``adk run contributing/samples/managed_agent/basic``. See +the README for the required environment / auth setup. +""" + +import os + +from google.adk.agents import ManagedAgent +from google.adk.tools import google_search + +# The Managed Agent id served by the Managed Agents API. Override with the +# MANAGED_AGENT_ID environment variable if your project has access to a +# different agent. +_DEFAULT_AGENT_ID = 'antigravity-preview-05-2026' + +root_agent = ManagedAgent( + name='managed_search_agent', + agent_id=os.environ.get('MANAGED_AGENT_ID', _DEFAULT_AGENT_ID), + # Provision a remote sandbox for the agent. The environment id is recovered + # from prior events, so follow-up turns reuse the same sandbox. + environment={'type': 'remote'}, + # Only server-side tools are supported today. google_search is an ADK + # built-in tool that executes on the server. + tools=[google_search], +) diff --git a/contributing/samples/managed_agent/code_execution/README.md b/contributing/samples/managed_agent/code_execution/README.md new file mode 100644 index 00000000000..1b8b0b5215c --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/README.md @@ -0,0 +1,54 @@ +# Managed Agent - Code Execution + +## Overview + +This sample runs a `ManagedAgent` configured with the built-in **code execution** +tool so it can write and run code server-side to compute answers. + +Unlike a regular `LlmAgent` (which enables code execution via +`code_executor=BuiltInCodeExecutor()`), `ManagedAgent` has no `code_executor` +field. Instead you pass the raw built-in tool config +`types.Tool(code_execution=types.ToolCodeExecution())` in `tools` -- the same +config `BuiltInCodeExecutor` produces under the hood. This makes the sample a +demonstration of the raw `types.Tool` server-side tool path. + +## Sample Inputs + +- `What is the sum of the first 50 prime numbers? Use code to compute it.` + + The model writes and runs code server-side; the answer (5117) comes from the + executed code rather than the model guessing. + +- `Now do the same for the first 100 primes.` + + A follow-up turn that reuses the recovered remote sandbox and the previous + interaction (answer: 24133), demonstrating multi-turn chaining. + +## Graph + +```mermaid +graph LR + User -->|message| ManagedAgent + ManagedAgent -->|interactions.create| ManagedAgentsAPI + ManagedAgentsAPI -->|server-side code execution| ManagedAgentsAPI + ManagedAgentsAPI -->|streamed events| ManagedAgent + ManagedAgent -->|answer| User +``` + +## How To + +- **Create the agent**: instantiate `ManagedAgent` with an `agent_id`, an + `environment` spec, and + `tools=[types.Tool(code_execution=types.ToolCodeExecution())]`. No `model` is + set -- the model is part of the managed agent on the server. +- **Enable code execution**: `ManagedAgent` has no `code_executor` field, so the + raw `types.Tool(code_execution=...)` config is passed in `tools`. The + interactions converter turns it into the server-side `code_execution` tool. +- **Provision a sandbox**: `environment={'type': 'remote'}` requests a fresh + remote sandbox. The resulting environment id is stored on emitted events, so + subsequent turns automatically recover and reuse it. +- **Multi-turn chaining**: the agent recovers the `previous_interaction_id` from + the session events, so follow-up turns continue the same interaction without + any extra wiring. +- **Drive it**: a `ManagedAgent` is a `BaseAgent`, so a standard `Runner` runs + it just like any other agent. diff --git a/contributing/samples/managed_agent/code_execution/__init__.py b/contributing/samples/managed_agent/code_execution/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/managed_agent/code_execution/agent.py b/contributing/samples/managed_agent/code_execution/agent.py new file mode 100644 index 00000000000..dfc69436a2f --- /dev/null +++ b/contributing/samples/managed_agent/code_execution/agent.py @@ -0,0 +1,55 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A ManagedAgent that runs server-side code execution. + +``ManagedAgent`` calls the Managed Agents API directly from its run loop instead +of running a local model loop. It currently supports server-side tools only. + +Unlike ``LlmAgent``, ``ManagedAgent`` has no ``code_executor`` field, so code +execution is enabled by passing the raw built-in tool config +``types.Tool(code_execution=types.ToolCodeExecution())`` in ``tools`` -- the +same config ``BuiltInCodeExecutor`` produces under the hood. The model writes +and runs code on the server to compute answers. + +A fresh remote sandbox is provisioned via ``environment={'type': 'remote'}``; +the environment id is recovered from prior events so multi-turn conversations +reuse the same sandbox. + +Run with ``adk web`` / +``adk run contributing/samples/managed_agent/code_execution``. See the README +for the required environment / auth setup. +""" + +import os + +from google.adk.agents import ManagedAgent +from google.genai import types + +# The Managed Agent id served by the Managed Agents API. Override with the +# MANAGED_AGENT_ID environment variable if your project has access to a +# different agent. +_DEFAULT_AGENT_ID = 'antigravity-preview-05-2026' + +root_agent = ManagedAgent( + name='managed_code_execution_agent', + agent_id=os.environ.get('MANAGED_AGENT_ID', _DEFAULT_AGENT_ID), + # Provision a remote sandbox for the agent. The environment id is recovered + # from prior events, so follow-up turns reuse the same sandbox. + environment={'type': 'remote'}, + # ManagedAgent has no `code_executor` field; enable server-side code + # execution by passing the raw built-in tool config. This is the same config + # BuiltInCodeExecutor appends for a regular LlmAgent. + tools=[types.Tool(code_execution=types.ToolCodeExecution())], +) diff --git a/pyproject.toml b/pyproject.toml index 0972f353d6e..5c617ee8cdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -306,6 +306,9 @@ python_version = "3.11" disable_error_code = [ "import-not-found", "import-untyped", "unused-ignore" ] strict = true plugins = [ "pydantic.mypy" ] +overrides = [ { module = [ + "google.auth.*", +], ignore_missing_imports = true, follow_imports = "skip" } ] [tool.pytest] ini_options.testpaths = [ "tests" ] diff --git a/src/google/adk/agents/loop_agent.py b/src/google/adk/agents/loop_agent.py index 6bf52214048..5d289bf49c8 100644 --- a/src/google/adk/agents/loop_agent.py +++ b/src/google/adk/agents/loop_agent.py @@ -51,8 +51,8 @@ class LoopAgentState(BaseAgentState): @deprecated( - 'LoopAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'LoopAgent is deprecated in favor of Workflow and will be removed in a' + ' future version. Workflow cannot yet be used as an LlmAgent sub-agent.' ) class LoopAgent(BaseAgent): """A shell agent that run its sub-agents in a loop. @@ -61,8 +61,8 @@ class LoopAgent(BaseAgent): reached, the loop agent will stop. .. deprecated:: - LoopAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + LoopAgent is deprecated in favor of Workflow and will be removed in a + future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[type[BaseAgentConfig]] = LoopAgentConfig diff --git a/src/google/adk/agents/parallel_agent.py b/src/google/adk/agents/parallel_agent.py index 8e98a30d867..dfd1e7749cb 100644 --- a/src/google/adk/agents/parallel_agent.py +++ b/src/google/adk/agents/parallel_agent.py @@ -158,8 +158,8 @@ async def process_an_agent(events_for_one_agent): @deprecated( - 'ParallelAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'ParallelAgent is deprecated in favor of Workflow and will be removed in' + ' a future version. Workflow cannot yet be used as an LlmAgent sub-agent.' ) class ParallelAgent(BaseAgent): """A shell agent that runs its sub-agents in parallel in an isolated manner. @@ -171,8 +171,8 @@ class ParallelAgent(BaseAgent): - Generating multiple responses for review by a subsequent evaluation agent. .. deprecated:: - ParallelAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + ParallelAgent is deprecated in favor of Workflow and will be removed in a + future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[type[BaseAgentConfig]] = ParallelAgentConfig diff --git a/src/google/adk/agents/sequential_agent.py b/src/google/adk/agents/sequential_agent.py index 01510ff4cbb..3b690175363 100644 --- a/src/google/adk/agents/sequential_agent.py +++ b/src/google/adk/agents/sequential_agent.py @@ -47,15 +47,16 @@ class SequentialAgentState(BaseAgentState): @deprecated( - 'SequentialAgent is deprecated and will be removed in future versions.' - ' Please use Workflow instead.' + 'SequentialAgent is deprecated in favor of Workflow and will be removed' + ' in a future version. Workflow cannot yet be used as an LlmAgent' + ' sub-agent.' ) class SequentialAgent(BaseAgent): """A shell agent that runs its sub-agents in sequence. .. deprecated:: - SequentialAgent is deprecated and will be removed in future versions. - Please use Workflow instead. + SequentialAgent is deprecated in favor of Workflow and will be removed in + a future version. Workflow cannot yet be used as an LlmAgent sub-agent. """ config_type: ClassVar[Type[BaseAgentConfig]] = SequentialAgentConfig diff --git a/src/google/adk/auth/auth_credential.py b/src/google/adk/auth/auth_credential.py index 4a2add823c6..747c21c987f 100644 --- a/src/google/adk/auth/auth_credential.py +++ b/src/google/adk/auth/auth_credential.py @@ -87,6 +87,7 @@ class OAuth2Auth(BaseModelWithConfig): expires_at: int | None = None expires_in: int | None = None audience: str | None = None + prompt: str | None = None code_verifier: str | None = None code_challenge_method: str | None = None token_endpoint_auth_method: ( diff --git a/src/google/adk/auth/auth_handler.py b/src/google/adk/auth/auth_handler.py index 9dce6b56afb..ea3aec52283 100644 --- a/src/google/adk/auth/auth_handler.py +++ b/src/google/adk/auth/auth_handler.py @@ -211,7 +211,7 @@ def generate_auth_uri( ) params = { "access_type": "offline", - "prompt": "consent", + "prompt": auth_credential.oauth2.prompt or "consent", } if auth_credential.oauth2.audience: params["audience"] = auth_credential.oauth2.audience diff --git a/src/google/adk/cli/api_server.py b/src/google/adk/cli/api_server.py index 1715aa29c01..aa529ddf980 100644 --- a/src/google/adk/cli/api_server.py +++ b/src/google/adk/cli/api_server.py @@ -165,6 +165,48 @@ def _get_scope_header( return None +import ipaddress as _ipaddress + +_LOOPBACK_HOSTNAMES = frozenset({"localhost"}) + + +def _is_loopback_address(host: str) -> bool: + """Return True if *host* (with or without a port) refers to a loopback address. + + Handles all four forms produced by browsers and uvicorn: + - Plain IPv4: "127.0.0.1" + - IPv4 with port: "127.0.0.1:8000" + - Bracketed IPv6: "[::1]" + - Bracketed IPv6+port: "[::1]:8000" + - Plain IPv6 (scope): "::1" (ASGI server tuple value) + - Hostname: "localhost" + - Hostname with port: "localhost:8000" + """ + bare = host + if bare.startswith("["): + # Bracketed IPv6: [addr] or [addr]:port + end = bare.find("]") + if end != -1: + bare = bare[1:end] + elif bare.count(":") == 1: + # IPv4:port or hostname:port (IPv6 without brackets has > 1 colon) + bare = bare.rsplit(":", 1)[0] + if bare in _LOOPBACK_HOSTNAMES: + return True + try: + return _ipaddress.ip_address(bare).is_loopback + except ValueError: + return False + + +def _get_server_host(scope: dict[str, Any]) -> Optional[str]: + """Return the host the server is actually bound to (from ASGI server port).""" + server = scope.get("server") + if server and len(server) == 2: + return str(server[0]) + return None + + def _get_request_origin(scope: dict[str, Any]) -> Optional[str]: """Compute the effective origin for the current HTTP/WebSocket request.""" forwarded = _get_scope_header(scope, b"forwarded") @@ -201,12 +243,39 @@ def _is_request_origin_allowed( allowed_origin_regex: Optional[re.Pattern[str]], has_configured_allowed_origins: bool, ) -> bool: - """Validate an Origin header against explicit config or same-origin.""" + """Validate an Origin header against explicit config or same-origin. + + DNS-rebinding protection: when the server is bound to a loopback address + (127.0.0.1 / ::1 / localhost) and no explicit allow-origins have been + configured, we additionally require that the request's Origin header also + resolves to a loopback host. This prevents a DNS-rebinding attack where + an external page temporarily resolves to 127.0.0.1 and then POSTs to the + local development server by matching its own (evil.com) origin against the + Host header it controls. + """ if has_configured_allowed_origins and _is_origin_allowed( origin, allowed_literal_origins, allowed_origin_regex ): return True + # DNS-rebinding guard: if the server is on loopback and no explicit + # allow-origins list is configured, only permit origins whose host is also + # loopback. This mirrors the protection used by the MCP go-sdk SSEHandler. + server_host = _get_server_host(scope) + if ( + not has_configured_allowed_origins + and server_host is not None + and _is_loopback_address(server_host) + ): + try: + from urllib.parse import urlparse # noqa: PLC0415 (local import OK here) + + origin_host = urlparse(origin).hostname or "" + except Exception: # pylint: disable=broad-except + return False + if not _is_loopback_address(origin_host): + return False + request_origin = _get_request_origin(scope) if request_origin is None: return False diff --git a/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py b/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py index 1862272cf3b..985011fe355 100644 --- a/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py +++ b/src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py @@ -146,7 +146,7 @@ # Definition of Conversation History The Conversation History is the actual dialogue between the User Simulator and the Agent. -The Conversation History may not be complete, but the exsisting dialogue should adhere to the Conversation Plan. +The Conversation History may not be complete, but the existing dialogue should adhere to the Conversation Plan. The Conversation History may contain instances where the User Simulator troubleshoots an incorrect/inappropriate response from the Agent in order to enforce the Conversation Plan. The Conversation History is finished only when the User Simulator outputs `{{ stop_signal }}` in its response. If this token is missing, the conversation between the User Simulator and the Agent has not finished, and more turns can be generated. diff --git a/src/google/adk/telemetry/google_cloud.py b/src/google/adk/telemetry/google_cloud.py index 0f1e12e90f5..a0c8f7db398 100644 --- a/src/google/adk/telemetry/google_cloud.py +++ b/src/google/adk/telemetry/google_cloud.py @@ -43,6 +43,13 @@ logger = logging.getLogger("google_adk." + __name__) +try: + from opentelemetry.semconv._incubating.attributes.cloud_attributes import CLOUD_RESOURCE_ID +except ImportError: + # cloud.resource_id only lives in the private _incubating package; fall back + # to the literal key the Agent Engine dashboard filters on if that path moves. + CLOUD_RESOURCE_ID = "cloud.resource_id" + _GCP_LOG_NAME_ENV_VARIABLE_NAME = "GOOGLE_CLOUD_DEFAULT_LOG_NAME" _DEFAULT_LOG_NAME = "adk-otel" @@ -246,7 +253,7 @@ def get_gcp_resource(project_id: Optional[str] = None) -> Resource: ), } if cloud_resource_id is not None: - resource_attributes["cloud.resource.id"] = cloud_resource_id + resource_attributes[CLOUD_RESOURCE_ID] = cloud_resource_id if agent_engine_id: resource = Resource.create(attributes=resource_attributes).merge( diff --git a/src/google/adk/tools/google_api_tool/google_api_toolset.py b/src/google/adk/tools/google_api_tool/google_api_toolset.py index 35d130c7ab5..aac8cd6b500 100644 --- a/src/google/adk/tools/google_api_tool/google_api_toolset.py +++ b/src/google/adk/tools/google_api_tool/google_api_toolset.py @@ -90,8 +90,12 @@ def __init__( cert_path, key_path, passphrase = self._mtls_certs.get_certs() if cert_path and key_path and passphrase: - def client_factory(): - return httpx.AsyncClient(cert=(cert_path, key_path, passphrase)) + def client_factory() -> httpx.AsyncClient: + if passphrase: + return httpx.AsyncClient( + cert=(cert_path, key_path, passphrase) # type: ignore[arg-type] + ) + return httpx.AsyncClient(cert=(cert_path, key_path)) self._httpx_client_factory = client_factory diff --git a/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py b/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py index b6eab32be88..6fd704cd703 100644 --- a/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py +++ b/src/google/adk/tools/google_api_tool/googleapi_to_openapi_converter.py @@ -130,6 +130,9 @@ def convert(self) -> Dict[str, Any]: if not self._google_api_spec: self.fetch_google_api_spec() + if self._google_api_spec is None: + raise RuntimeError("Failed to initialize Google API specification.") + # Convert basic API information self._convert_info() @@ -170,6 +173,8 @@ def _convert_info(self) -> None: def _convert_servers(self) -> None: """Convert server information.""" + if self._google_api_spec is None: + raise RuntimeError("API spec must be initialized before conversion.") use_client_cert = getattr(self, "_use_client_cert", False) if use_client_cert and "mtlsRootUrl" in self._google_api_spec: root_url = self._google_api_spec["mtlsRootUrl"] diff --git a/src/google/adk/tools/google_search_tool.py b/src/google/adk/tools/google_search_tool.py index 65b0a37d002..8e4b384c885 100644 --- a/src/google/adk/tools/google_search_tool.py +++ b/src/google/adk/tools/google_search_tool.py @@ -31,7 +31,7 @@ class GoogleSearchTool(BaseTool): - """A built-in tool that is automatically invoked by Gemini 2 models to retrieve search results from Google Search. + """A built-in tool that is automatically invoked by Gemini models to retrieve search results from Google Search. This tool operates internally within the model and does not require or perform local code execution. diff --git a/src/google/adk/workflow/_llm_agent_wrapper.py b/src/google/adk/workflow/_llm_agent_wrapper.py index a4f0dc900ed..20fe74ace6b 100644 --- a/src/google/adk/workflow/_llm_agent_wrapper.py +++ b/src/google/adk/workflow/_llm_agent_wrapper.py @@ -394,10 +394,6 @@ async def run_llm_agent_as_node( break # close this run_iter; outer loop re-enters if event.actions.transfer_to_agent: target_name = event.actions.transfer_to_agent - if target_name == agent.name: - raise ValueError( - f"Agent '{target_name}' cannot transfer to itself." - ) from ..agents.llm_agent import LlmAgent diff --git a/tests/integration/test_managed_agent.py b/tests/integration/test_managed_agent.py index befc57ab5a9..dde6d56a9a3 100644 --- a/tests/integration/test_managed_agent.py +++ b/tests/integration/test_managed_agent.py @@ -86,3 +86,38 @@ async def test_google_search_project_hail_mary(): assert ( 'james ortiz' in answer.lower() ), f'expected the grounded answer to contain "James Ortiz"; got: {answer!r}' + + +@pytest.mark.asyncio +async def test_code_execution_prime_sum(): + agent = ManagedAgent( + name='managed_code_execution_agent', + agent_id=_AGENT_ID, + environment={'type': 'remote'}, + tools=[types.Tool(code_execution=types.ToolCodeExecution())], + ) + session_service = InMemorySessionService() + runner = Runner( + app_name='managed_agent_it', + agent=agent, + session_service=session_service, + ) + session = await session_service.create_session( + app_name='managed_agent_it', user_id='test_user' + ) + + events = await _run_turn( + runner, + session, + 'What is the sum of the first 50 prime numbers? Use code to compute it.', + ) + + answer = _joined_text(events) + print('\n=== ManagedAgent code execution answer ===\n', answer) + # The model may stream the number with thousands separators and/or stray + # whitespace (e.g. "5,117" or "5, 117"), so remove all whitespace and commas + # before matching the code-executed sum. + normalized = ''.join(answer.split()).replace(',', '') + assert ( + '5117' in normalized + ), f'expected the code-executed sum 5117; got: {answer!r}' diff --git a/tests/unittests/agents/test_loop_agent.py b/tests/unittests/agents/test_loop_agent.py index 0e23d9d42cd..e4465883890 100644 --- a/tests/unittests/agents/test_loop_agent.py +++ b/tests/unittests/agents/test_loop_agent.py @@ -285,3 +285,8 @@ def mock_should_pause(event): # Verify that the sub-agent state was NOT reset assert agent.name in parent_ctx.agent_states assert parent_ctx.agent_states[agent.name] == {'some_key': 'some_value'} + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + LoopAgent(name='deprecated_loop', sub_agents=[]) diff --git a/tests/unittests/agents/test_parallel_agent.py b/tests/unittests/agents/test_parallel_agent.py index 305911c100e..76e2405ec11 100644 --- a/tests/unittests/agents/test_parallel_agent.py +++ b/tests/unittests/agents/test_parallel_agent.py @@ -409,3 +409,8 @@ async def test_merge_agent_run_pre_3_11_no_aclose_error_on_failure(): # would raise RuntimeError here. for agen in agent_runs: await agen.aclose() + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + ParallelAgent(name='deprecated_parallel', sub_agents=[]) diff --git a/tests/unittests/agents/test_sequential_agent.py b/tests/unittests/agents/test_sequential_agent.py index 85523d2dca1..aadb5ad5800 100644 --- a/tests/unittests/agents/test_sequential_agent.py +++ b/tests/unittests/agents/test_sequential_agent.py @@ -201,3 +201,8 @@ async def test_run_live(request: pytest.FixtureRequest): assert events[1].author == agent_2.name assert events[0].content.parts[0].text == f'Hello, live {agent_1.name}!' assert events[1].content.parts[0].text == f'Hello, live {agent_2.name}!' + + +def test_deprecation_mentions_sub_agent_limitation(): + with pytest.warns(DeprecationWarning, match='sub-agent'): + SequentialAgent(name='deprecated_sequential', sub_agents=[]) diff --git a/tests/unittests/auth/test_auth_handler.py b/tests/unittests/auth/test_auth_handler.py index c35777acab5..c2ef39127af 100644 --- a/tests/unittests/auth/test_auth_handler.py +++ b/tests/unittests/auth/test_auth_handler.py @@ -67,6 +67,8 @@ def create_authorization_url(self, url, **kwargs): params = f"client_id={self.client_id}&scope={self.scope}" if kwargs.get("audience"): params += f"&audience={kwargs.get('audience')}" + if kwargs.get("prompt"): + params += f"&prompt={kwargs.get('prompt')}" return f"{url}?{params}", "mock_state" def fetch_token( @@ -251,6 +253,25 @@ def test_generate_auth_uri_with_audience_and_prompt( result = handler.generate_auth_uri() assert "audience=test_audience" in result.oauth2.auth_uri + assert "prompt=consent" in result.oauth2.auth_uri + + @patch("google.adk.auth.auth_handler.OAuth2Session", MockOAuth2Session) + def test_generate_auth_uri_with_custom_prompt( + self, openid_auth_scheme, oauth2_credentials + ): + """Test generating an auth URI with a custom prompt override.""" + oauth2_credentials.oauth2.prompt = "none" + exchanged = oauth2_credentials.model_copy(deep=True) + + config = AuthConfig( + auth_scheme=openid_auth_scheme, + raw_auth_credential=oauth2_credentials, + exchanged_auth_credential=exchanged, + ) + handler = AuthHandler(config) + result = handler.generate_auth_uri() + + assert "prompt=none" in result.oauth2.auth_uri @patch("google.adk.auth.auth_handler.OAuth2Session", MockOAuth2Session) def test_generate_auth_uri_openid( @@ -299,7 +320,7 @@ def test_generate_auth_uri_client_credentials_with_missing_scopes( assert ( result.oauth2.auth_uri - == "https://example.com/oauth2/token?client_id=mock_client_id&scope=" + == "https://example.com/oauth2/token?client_id=mock_client_id&scope=&prompt=consent" ) assert result.oauth2.state == "mock_state" diff --git a/tests/unittests/cli/test_dns_rebinding_protection.py b/tests/unittests/cli/test_dns_rebinding_protection.py new file mode 100644 index 00000000000..bf0a6d347ed --- /dev/null +++ b/tests/unittests/cli/test_dns_rebinding_protection.py @@ -0,0 +1,196 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for DNS-rebinding protection in _OriginCheckMiddleware.""" + +from google.adk.cli.api_server import _is_loopback_address +from google.adk.cli.api_server import _is_request_origin_allowed +import pytest + + +class TestIsLoopbackAddress: + """Unit tests for _is_loopback_address.""" + + @pytest.mark.parametrize( + "host", + [ + "127.0.0.1", + "localhost", + "::1", + "[::1]", + "127.0.0.1:8000", + "localhost:8000", + "[::1]:8000", + "127.1.2.3", # any 127.x.x.x is loopback + ], + ) + def test_loopback_hosts(self, host: str): + assert _is_loopback_address(host), f"{host!r} should be loopback" + + @pytest.mark.parametrize( + "host", + [ + "evil.com", + "127.evil.com", + "0.0.0.0", + "192.168.1.1", + "10.0.0.1", + "128.0.0.1", + "", + ], + ) + def test_non_loopback_hosts(self, host: str): + assert not _is_loopback_address(host), f"{host!r} should NOT be loopback" + + +class TestDnsRebindingProtection: + """Tests that DNS-rebinding attacks are blocked when server is on loopback.""" + + def _make_scope( + self, server_host: str = "127.0.0.1", host_header: str = "127.0.0.1:8000" + ) -> dict: + """Build a minimal ASGI scope for testing.""" + return { + "type": "http", + "method": "POST", + "server": (server_host, 8000), + "headers": [ + (b"host", host_header.encode()), + ], + "scheme": "http", + } + + # --- DNS rebinding scenarios (should be BLOCKED) --- + + def test_dns_rebinding_evil_origin_loopback_server_no_configured_origins( + self, + ): + """Attacker page (evil.com) DNS-rebinds to 127.0.0.1 and sends a POST. + + Browser sends Origin: http://evil.com, Host: evil.com. + Server is bound to 127.0.0.1. + No explicit allow-origins configured. + Expected: BLOCKED. + """ + scope = self._make_scope( + server_host="127.0.0.1", host_header="evil.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert ( + not result + ), "DNS-rebinding from evil.com should be blocked on loopback server" + + def test_dns_rebinding_127_evil_origin(self): + """Origin header host starts with '127.' but is a hostname (127.evil.com).""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="127.evil.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://127.evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + def test_dns_rebinding_localhost_server(self): + """Same attack, server bound as 'localhost'.""" + scope = self._make_scope(server_host="localhost", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + def test_dns_rebinding_ipv6_loopback_server(self): + """Same attack, server bound to ::1.""" + scope = self._make_scope(server_host="::1", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert not result + + # --- Legitimate same-origin requests (should be ALLOWED) --- + + def test_same_origin_localhost_allowed(self): + """Legitimate browser request from localhost UI to localhost server.""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="127.0.0.1:8000" + ) + result = _is_request_origin_allowed( + origin="http://127.0.0.1:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result, "Same-origin localhost request should be allowed" + + def test_same_origin_localhost_named(self): + """Browser opens http://localhost:8000 -> requests to localhost:8000.""" + scope = self._make_scope( + server_host="127.0.0.1", host_header="localhost:8000" + ) + result = _is_request_origin_allowed( + origin="http://localhost:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result + + # --- Explicit allow-origins configured (allow-list bypasses DNS guard) --- + + def test_explicit_allowlist_overrides_dns_rebinding_guard(self): + """If the developer explicitly allows evil.com, it should be permitted.""" + scope = self._make_scope(server_host="127.0.0.1", host_header="evil.com") + result = _is_request_origin_allowed( + origin="http://evil.com", + scope=scope, + allowed_literal_origins=["http://evil.com"], + allowed_origin_regex=None, + has_configured_allowed_origins=True, + ) + assert result, "Explicitly allowed origin should still pass" + + # --- Non-loopback server (protection does not apply) --- + + def test_non_loopback_server_no_dns_guard(self): + """Server bound to 0.0.0.0 — DNS guard must not interfere with same-origin check.""" + scope = self._make_scope( + server_host="0.0.0.0", host_header="example.com:8000" + ) + result = _is_request_origin_allowed( + origin="http://example.com:8000", + scope=scope, + allowed_literal_origins=[], + allowed_origin_regex=None, + has_configured_allowed_origins=False, + ) + assert result, "Same-origin on public server should be allowed" diff --git a/tests/unittests/runners/test_runner_node.py b/tests/unittests/runners/test_runner_node.py index 9559c0933cb..075b2b6765f 100644 --- a/tests/unittests/runners/test_runner_node.py +++ b/tests/unittests/runners/test_runner_node.py @@ -26,9 +26,12 @@ from google.adk.agents.callback_context import CallbackContext from google.adk.agents.context import Context +from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.run_config import RunConfig +from google.adk.apps.app import App from google.adk.events.event import Event +from google.adk.plugins.base_plugin import BasePlugin from google.adk.runners import Runner from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.workflow import node @@ -1393,3 +1396,43 @@ async def _run_impl( assert call_counts['child'] == 2 outputs2 = [e.output for e in events2 if e.output is not None] assert 'child_out_2' in outputs2 + + +# --------------------------------------------------------------------------- +# Plugin lifecycle on the node path +# --------------------------------------------------------------------------- + + +class _AfterRunCountingPlugin(BasePlugin): + """Counts how many times after_run_callback is dispatched.""" + + def __init__(self) -> None: + super().__init__(name='after_run_counter') + self.after_run_calls = 0 + + async def after_run_callback( + self, *, invocation_context: InvocationContext + ) -> None: + self.after_run_calls += 1 + + +@pytest.mark.asyncio +async def test_after_run_callback_dispatched_on_workflow_root(): + """Runner dispatches plugin after_run_callback on a Workflow(BaseNode) root.""" + + def terminal(node_input: str) -> str: + return node_input.upper() + + plugin = _AfterRunCountingPlugin() + workflow = Workflow(name='wf', edges=[(START, terminal)]) + app = App(name='test', root_agent=workflow, plugins=[plugin]) + ss = InMemorySessionService() + runner = Runner(app=app, session_service=ss) + session = await ss.create_session(app_name='test', user_id='u') + + async for _ in runner.run_async( + user_id='u', session_id=session.id, new_message=_user_message('hi') + ): + pass + + assert plugin.after_run_calls == 1 diff --git a/tests/unittests/telemetry/test_google_cloud.py b/tests/unittests/telemetry/test_google_cloud.py index 7559f9f2011..ac1aba1971b 100644 --- a/tests/unittests/telemetry/test_google_cloud.py +++ b/tests/unittests/telemetry/test_google_cloud.py @@ -113,6 +113,25 @@ def test_get_gcp_resource( ) +def test_get_gcp_resource_sets_standard_cloud_resource_id( + monkeypatch: pytest.MonkeyPatch, +): + # Arrange. + monkeypatch.setenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", "1234567890") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + + # Act. + otel_resource = get_gcp_resource("my-project") + + # Assert. + # The Agent Engine dashboard filters on the OTel-standard key. + assert otel_resource.attributes.get("cloud.resource_id") == ( + "//aiplatform.googleapis.com/projects/my-project" + "/locations/us-central1/reasoningEngines/1234567890" + ) + assert "cloud.resource.id" not in otel_resource.attributes + + @mock.patch.object(mtls, "should_use_client_cert", autospec=True) def test_use_client_cert_effective_from_mtls(mock_should_use): mock_should_use.return_value = True