Skip to content

Commit 7a201a8

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents ce012da + 5020954 commit 7a201a8

9 files changed

Lines changed: 173 additions & 103 deletions

File tree

src/google/adk/cli/cli_deploy.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import sys
2323
import traceback
2424
from typing import Final
25+
from typing import Literal
2526
from typing import Optional
2627
import warnings
2728

@@ -1170,6 +1171,9 @@ def to_gke(
11701171
memory_service_uri: Optional[str] = None,
11711172
use_local_storage: bool = False,
11721173
a2a: bool = False,
1174+
service_type: Literal[
1175+
'ClusterIP', 'NodePort', 'LoadBalancer'
1176+
] = 'ClusterIP',
11731177
):
11741178
"""Deploys an agent to Google Kubernetes Engine(GKE).
11751179
@@ -1197,6 +1201,7 @@ def to_gke(
11971201
artifact_service_uri: The URI of the artifact service.
11981202
memory_service_uri: The URI of the memory service.
11991203
use_local_storage: Whether to use local .adk storage in the container.
1204+
service_type: The Kubernetes Service type (default: ClusterIP).
12001205
"""
12011206
click.secho(
12021207
'\n🚀 Starting ADK Agent Deployment to GKE...', fg='cyan', bold=True
@@ -1334,7 +1339,7 @@ def to_gke(
13341339
metadata:
13351340
name: {service_name}
13361341
spec:
1337-
type: LoadBalancer
1342+
type: {service_type}
13381343
selector:
13391344
app: {service_name}
13401345
ports:
@@ -1388,3 +1393,11 @@ def to_gke(
13881393
click.secho(
13891394
'\n🎉 Deployment to GKE finished successfully!', fg='cyan', bold=True
13901395
)
1396+
if service_type == 'ClusterIP':
1397+
click.echo(
1398+
'\nThe service is only reachable from within the cluster.'
1399+
' To access it locally, run:'
1400+
f'\n kubectl port-forward svc/{service_name} {port}:{port}'
1401+
'\n\nTo expose the service externally, add a Gateway or'
1402+
' re-deploy with --service_type=LoadBalancer.'
1403+
)

src/google/adk/cli/cli_tools_click.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,17 @@ def cli_deploy_agent_engine(
22382238
default="INFO",
22392239
help="Optional. Set the logging level",
22402240
)
2241+
@click.option(
2242+
"--service_type",
2243+
type=click.Choice(["ClusterIP", "LoadBalancer"], case_sensitive=True),
2244+
default="ClusterIP",
2245+
show_default=True,
2246+
help=(
2247+
"Optional. The Kubernetes Service type for the deployed agent."
2248+
" ClusterIP (default) keeps the service cluster-internal;"
2249+
" use LoadBalancer to expose a public IP."
2250+
),
2251+
)
22412252
@click.option(
22422253
"--temp_folder",
22432254
type=str,
@@ -2281,6 +2292,7 @@ def cli_deploy_gke(
22812292
otel_to_cloud: bool,
22822293
with_ui: bool,
22832294
adk_version: str,
2295+
service_type: str,
22842296
log_level: Optional[str] = None,
22852297
session_service_uri: Optional[str] = None,
22862298
artifact_service_uri: Optional[str] = None,
@@ -2312,6 +2324,7 @@ def cli_deploy_gke(
23122324
with_ui=with_ui,
23132325
log_level=log_level,
23142326
adk_version=adk_version,
2327+
service_type=service_type,
23152328
session_service_uri=session_service_uri,
23162329
artifact_service_uri=artifact_service_uri,
23172330
memory_service_uri=memory_service_uri,

src/google/adk/cli/fast_api.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def _normalize_relative_path(path: str) -> str:
291291
def _has_parent_reference(path: str) -> bool:
292292
return any(part == ".." for part in path.split("/"))
293293

294-
_ALLOWED_UPLOAD_EXTENSIONS = frozenset({".yaml", ".yml"})
294+
_ALLOWED_EXTENSIONS = frozenset({".yaml", ".yml"})
295295

296296
def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
297297
if not filename:
@@ -307,10 +307,10 @@ def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
307307
if _has_parent_reference(rel_path):
308308
raise ValueError(f"Path traversal rejected: {filename!r}")
309309
ext = os.path.splitext(rel_path)[1].lower()
310-
if ext not in _ALLOWED_UPLOAD_EXTENSIONS:
310+
if ext not in _ALLOWED_EXTENSIONS:
311311
raise ValueError(
312312
f"File type not allowed: {rel_path!r}"
313-
f" (allowed: {', '.join(sorted(_ALLOWED_UPLOAD_EXTENSIONS))})"
313+
f" (allowed: {', '.join(sorted(_ALLOWED_EXTENSIONS))})"
314314
)
315315
return app_name, rel_path
316316

@@ -322,6 +322,12 @@ def _parse_file_path(file_path: str) -> str:
322322
raise ValueError(f"Absolute file_path rejected: {file_path!r}")
323323
if _has_parent_reference(file_path):
324324
raise ValueError(f"Path traversal rejected: {file_path!r}")
325+
ext = os.path.splitext(file_path)[1].lower()
326+
if ext not in _ALLOWED_EXTENSIONS:
327+
raise ValueError(
328+
f"File type not allowed: {file_path!r}"
329+
f" (allowed: {', '.join(sorted(_ALLOWED_EXTENSIONS))})"
330+
)
325331
return file_path
326332

327333
def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path:

src/google/adk/cli/utils/agent_loader.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import logging
2020
import os
2121
from pathlib import Path
22+
import re
2223
import sys
2324
from typing import Any
2425
from typing import Literal
@@ -187,8 +188,36 @@ def _load_from_yaml_config(
187188
) + e.args[1:]
188189
raise e
189190

191+
_VALID_AGENT_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
192+
193+
def _validate_agent_name(self, agent_name: str) -> None:
194+
"""Validate agent name to prevent arbitrary module imports."""
195+
# Strip the special agent prefix for validation
196+
if agent_name.startswith("__"):
197+
name_to_check = agent_name[2:]
198+
check_dir = os.path.abspath(SPECIAL_AGENTS_DIR)
199+
else:
200+
name_to_check = agent_name
201+
check_dir = self.agents_dir
202+
203+
if not self._VALID_AGENT_NAME_RE.match(name_to_check):
204+
raise ValueError(
205+
f"Invalid agent name: {agent_name!r}. Agent names must be valid"
206+
" Python identifiers (letters, digits, and underscores only)."
207+
)
208+
209+
# Verify the agent exists on disk before allowing import
210+
agent_path = Path(check_dir) / name_to_check
211+
agent_file = Path(check_dir) / f"{name_to_check}.py"
212+
if not (agent_path.is_dir() or agent_file.is_file()):
213+
raise ValueError(
214+
f"Agent not found: {agent_name!r}. No matching directory or module"
215+
f" exists in '{os.path.join(check_dir, name_to_check)}'."
216+
)
217+
190218
def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
191219
"""Internal logic to load an agent"""
220+
self._validate_agent_name(agent_name)
192221
# Determine the directory to use for loading
193222
if agent_name.startswith("__"):
194223
# Special agent: use special agents directory
@@ -335,18 +364,13 @@ def load_agent(self, agent_name: str) -> Union[BaseAgent, App]:
335364
def list_agents(self) -> list[str]:
336365
"""Lists all agents available in the agent loader (sorted alphabetically)."""
337366
base_path = Path.cwd() / self.agents_dir
338-
agent_names = []
339-
for x in os.listdir(base_path):
340-
if (
341-
os.path.isdir(os.path.join(base_path, x))
342-
and not x.startswith(".")
343-
and x != "__pycache__"
344-
):
345-
try:
346-
self._determine_agent_language(x)
347-
agent_names.append(x)
348-
except ValueError:
349-
continue
367+
agent_names = [
368+
x
369+
for x in os.listdir(base_path)
370+
if os.path.isdir(os.path.join(base_path, x))
371+
and not x.startswith(".")
372+
and x != "__pycache__"
373+
]
350374
agent_names.sort()
351375
return agent_names
352376

src/google/adk/skills/_utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import pathlib
2121
from typing import Union
2222

23+
from google.auth import credentials as auth
2324
from google.cloud import storage
2425
from pydantic import ValidationError
2526
import yaml
@@ -301,6 +302,8 @@ def _list_skills_in_dir(
301302
def _list_skills_in_gcs_dir(
302303
bucket_name: str,
303304
skills_base_path: str = "",
305+
project_id: str | None = None,
306+
credentials: auth.Credentials | None = None,
304307
) -> Dict[str, models.Frontmatter]:
305308
"""List skills in a GCS directory.
306309
@@ -311,7 +314,7 @@ def _list_skills_in_gcs_dir(
311314
Returns:
312315
Dictionary mapping skill IDs to their frontmatter.
313316
"""
314-
client = storage.Client()
317+
client = storage.Client(project=project_id, credentials=credentials)
315318
bucket = client.bucket(bucket_name)
316319

317320
base_prefix = skills_base_path.strip("/")
@@ -350,13 +353,17 @@ def _load_skill_from_gcs_dir(
350353
bucket_name: str,
351354
skill_id: str,
352355
skills_base_path: str = "",
356+
project_id: str | None = None,
357+
credentials: auth.Credentials | None = None,
353358
) -> models.Skill:
354359
"""Load a complete skill from a GCS directory.
355360
356361
Args:
357362
bucket_name: Name of the GCS bucket.
358363
skill_id: The ID of the skill (directory name).
359364
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
365+
project_id: Project ID to use for GCS client.
366+
credentials: Credentials to use for GCS client.
360367
361368
Returns:
362369
Skill object with all components loaded.
@@ -366,7 +373,8 @@ def _load_skill_from_gcs_dir(
366373
ValueError: If SKILL.md is invalid or the skill name does not match
367374
the directory name.
368375
"""
369-
client = storage.Client()
376+
377+
client = storage.Client(project=project_id, credentials=credentials)
370378
bucket = client.bucket(bucket_name)
371379

372380
base_prefix = skills_base_path.strip("/")

src/google/adk/tools/bash_tool.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,12 @@ async def run_async(
141141
try:
142142
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
143143
return {
144-
"stdout": stdout.decode(),
145-
"stderr": stderr.decode(),
144+
"stdout": (
145+
stdout.decode() if stdout is not None else "<No stdout captured>"
146+
),
147+
"stderr": (
148+
stderr.decode() if stderr is not None else "<No stderr captured>"
149+
),
146150
"returncode": process.returncode,
147151
}
148152
except asyncio.TimeoutError:

tests/unittests/cli/test_fast_api.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,40 @@ def test_builder_save_allows_yaml_files(builder_test_client, tmp_path):
17591759
assert response.json() is True
17601760

17611761

1762+
def test_builder_get_rejects_non_yaml_file_paths(builder_test_client, tmp_path):
1763+
"""GET /builder/app/{app_name}?file_path=... rejects non-YAML extensions."""
1764+
app_root = tmp_path / "app"
1765+
app_root.mkdir(parents=True, exist_ok=True)
1766+
(app_root / ".env").write_text("SECRET=supersecret\n")
1767+
(app_root / "agent.py").write_text("root_agent = None\n")
1768+
(app_root / "config.json").write_text("{}\n")
1769+
1770+
for file_path in [".env", "agent.py", "config.json"]:
1771+
response = builder_test_client.get(
1772+
f"/builder/app/app?file_path={file_path}"
1773+
)
1774+
assert response.status_code == 200, f"Expected 200 for {file_path}"
1775+
assert response.text == "", f"Expected empty response for {file_path}"
1776+
1777+
1778+
def test_builder_get_allows_yaml_file_paths(builder_test_client, tmp_path):
1779+
"""GET /builder/app/{app_name}?file_path=... allows YAML extensions."""
1780+
app_root = tmp_path / "app"
1781+
app_root.mkdir(parents=True, exist_ok=True)
1782+
(app_root / "sub_agent.yaml").write_text("name: sub\n")
1783+
(app_root / "tool.yml").write_text("name: tool\n")
1784+
1785+
response = builder_test_client.get(
1786+
"/builder/app/app?file_path=sub_agent.yaml"
1787+
)
1788+
assert response.status_code == 200
1789+
assert response.text == "name: sub\n"
1790+
1791+
response = builder_test_client.get("/builder/app/app?file_path=tool.yml")
1792+
assert response.status_code == 200
1793+
assert response.text == "name: tool\n"
1794+
1795+
17621796
def test_builder_endpoints_not_registered_without_web(
17631797
mock_session_service,
17641798
mock_artifact_service,

0 commit comments

Comments
 (0)