fix: Harden YAML builder tmp save/cleanup

- Add path-safe helpers so all builder filesystem operations stay under <agents_dir>/<app_name> and reject traversal/invalid upload paths.
- Rework /builder/save to support tmp=true writes under <app>/tmp/<app>, promote tmp → app root on final save (preserving tools.py/tools/), then clean up tmp on success.
- Simplify /builder/app/{app_name}/cancel to best-effort delete tmp; update GET /builder/app/{app_name}?tmp=true to auto-recreate tmp from the app root and safely serve requested files.

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 852366567
This commit is contained in:
George Weale
2026-01-05 10:47:12 -08:00
committed by Copybara-Service
parent 3ec7ae3b8d
commit 6f259f08b3
2 changed files with 418 additions and 95 deletions
+189 -13
View File
@@ -17,6 +17,7 @@ import json
import logging
import os
from pathlib import Path
import signal
import sys
import tempfile
import time
@@ -31,6 +32,7 @@ from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App
from google.adk.artifacts.base_artifact_service import ArtifactVersion
from google.adk.cli import fast_api as fast_api_module
from google.adk.cli.fast_api import get_fast_api_app
from google.adk.errors.input_validation_error import InputValidationError
from google.adk.evaluation.eval_case import EvalCase
@@ -414,29 +416,41 @@ def test_app(
# Patch multiple services and signal handlers
with (
patch("signal.signal", return_value=None),
patch(
"google.adk.cli.fast_api.create_session_service_from_options",
patch.object(signal, "signal", autospec=True, return_value=None),
patch.object(
fast_api_module,
"create_session_service_from_options",
autospec=True,
return_value=mock_session_service,
),
patch(
"google.adk.cli.fast_api.create_artifact_service_from_options",
patch.object(
fast_api_module,
"create_artifact_service_from_options",
autospec=True,
return_value=mock_artifact_service,
),
patch(
"google.adk.cli.fast_api.create_memory_service_from_options",
patch.object(
fast_api_module,
"create_memory_service_from_options",
autospec=True,
return_value=mock_memory_service,
),
patch(
"google.adk.cli.fast_api.AgentLoader",
patch.object(
fast_api_module,
"AgentLoader",
autospec=True,
return_value=mock_agent_loader,
),
patch(
"google.adk.cli.fast_api.LocalEvalSetsManager",
patch.object(
fast_api_module,
"LocalEvalSetsManager",
autospec=True,
return_value=mock_eval_sets_manager,
),
patch(
"google.adk.cli.fast_api.LocalEvalSetResultsManager",
patch.object(
fast_api_module,
"LocalEvalSetResultsManager",
autospec=True,
return_value=mock_eval_set_results_manager,
),
):
@@ -459,6 +473,70 @@ def test_app(
return client
@pytest.fixture
def builder_test_client(
tmp_path,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
mock_eval_sets_manager,
mock_eval_set_results_manager,
):
"""Return a TestClient rooted in a temporary agents directory."""
with (
patch.object(signal, "signal", autospec=True, return_value=None),
patch.object(
fast_api_module,
"create_session_service_from_options",
autospec=True,
return_value=mock_session_service,
),
patch.object(
fast_api_module,
"create_artifact_service_from_options",
autospec=True,
return_value=mock_artifact_service,
),
patch.object(
fast_api_module,
"create_memory_service_from_options",
autospec=True,
return_value=mock_memory_service,
),
patch.object(
fast_api_module,
"AgentLoader",
autospec=True,
return_value=mock_agent_loader,
),
patch.object(
fast_api_module,
"LocalEvalSetsManager",
autospec=True,
return_value=mock_eval_sets_manager,
),
patch.object(
fast_api_module,
"LocalEvalSetResultsManager",
autospec=True,
return_value=mock_eval_set_results_manager,
),
):
app = get_fast_api_app(
agents_dir=str(tmp_path),
web=True,
session_service_uri="",
artifact_service_uri="",
memory_service_uri="",
allow_origins=["*"],
a2a=False,
host="127.0.0.1",
port=8000,
)
return TestClient(app)
@pytest.fixture
async def create_test_session(
test_app, test_session_info, mock_session_service
@@ -1175,5 +1253,103 @@ def test_patch_memory(test_app, create_test_session, mock_memory_service):
logger.info("Add session to memory test completed successfully")
def test_builder_final_save_preserves_tools_and_cleans_tmp(
builder_test_client, tmp_path
):
files = [
("files", ("app/__init__.py", b"from . import agent\n", "text/plain")),
("files", ("app/tools.py", b"def tool():\n return 1\n", "text/plain")),
(
"files",
("app/root_agent.yaml", b"name: app\n", "application/x-yaml"),
),
]
response = builder_test_client.post("/builder/save?tmp=true", files=files)
assert response.status_code == 200
assert response.json() is True
response = builder_test_client.post(
"/builder/save",
files=[(
"files",
(
"app/root_agent.yaml",
b"name: app_updated\n",
"application/x-yaml",
),
)],
)
assert response.status_code == 200
assert response.json() is True
assert (tmp_path / "app" / "tools.py").is_file()
assert not (tmp_path / "app" / "tmp" / "app").exists()
tmp_dir = tmp_path / "app" / "tmp"
assert not tmp_dir.exists() or not any(tmp_dir.iterdir())
def test_builder_cancel_deletes_tmp_idempotent(builder_test_client, tmp_path):
tmp_agent_root = tmp_path / "app" / "tmp" / "app"
tmp_agent_root.mkdir(parents=True, exist_ok=True)
(tmp_agent_root / "root_agent.yaml").write_text("name: app\n")
response = builder_test_client.post("/builder/app/app/cancel")
assert response.status_code == 200
assert response.json() is True
assert not (tmp_path / "app" / "tmp").exists()
response = builder_test_client.post("/builder/app/app/cancel")
assert response.status_code == 200
assert response.json() is True
assert not (tmp_path / "app" / "tmp").exists()
def test_builder_get_tmp_true_recreates_tmp(builder_test_client, tmp_path):
app_root = tmp_path / "app"
app_root.mkdir(parents=True, exist_ok=True)
(app_root / "root_agent.yaml").write_text("name: app\n")
nested_dir = app_root / "nested"
nested_dir.mkdir(parents=True, exist_ok=True)
(nested_dir / "nested.yaml").write_text("nested: true\n")
assert not (app_root / "tmp").exists()
response = builder_test_client.get("/builder/app/app?tmp=true")
assert response.status_code == 200
assert response.text == "name: app\n"
tmp_agent_root = app_root / "tmp" / "app"
assert (tmp_agent_root / "root_agent.yaml").is_file()
assert (tmp_agent_root / "nested" / "nested.yaml").is_file()
response = builder_test_client.get(
"/builder/app/app?tmp=true&file_path=nested/nested.yaml"
)
assert response.status_code == 200
assert response.text == "nested: true\n"
def test_builder_get_tmp_true_missing_app_returns_empty(
builder_test_client, tmp_path
):
response = builder_test_client.get("/builder/app/missing?tmp=true")
assert response.status_code == 200
assert response.text == ""
assert not (tmp_path / "missing").exists()
def test_builder_save_rejects_traversal(builder_test_client, tmp_path):
response = builder_test_client.post(
"/builder/save?tmp=true",
files=[(
"files",
("app/../escape.yaml", b"nope\n", "application/x-yaml"),
)],
)
assert response.status_code == 200
assert response.json() is False
assert not (tmp_path / "escape.yaml").exists()
assert not (tmp_path / "app" / "tmp" / "escape.yaml").exists()
if __name__ == "__main__":
pytest.main(["-xvs", __file__])