From a691f1a723af8a442912c6e3a53073c2cc5b3075 Mon Sep 17 00:00:00 2001 From: "steve.brazier" Date: Tue, 24 Feb 2026 09:44:21 +0100 Subject: [PATCH] fix: serve runtime-config.json dynamically instead of writing to package directory The dev UI frontend fetches runtime-config.json to discover the backendUrl (url_prefix). Previously this was written to the installed package directory at startup, which fails silently in read-only environments (e.g. containerized deployments with a read-only .venv). The frontend then falls back to the default empty backendUrl, causing 404s for all API calls when url_prefix is set. Replace the filesystem read/write with a pure builder method and serve the config via a dynamic GET endpoint that takes priority over the static mount. Co-Authored-By: Claude Opus 4.6 --- src/google/adk/cli/adk_web_server.py | 48 +++---------- .../cli/test_adk_web_server_run_live.py | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+), 37 deletions(-) diff --git a/src/google/adk/cli/adk_web_server.py b/src/google/adk/cli/adk_web_server.py index 48587bd559..f850bad0ee 100644 --- a/src/google/adk/cli/adk_web_server.py +++ b/src/google/adk/cli/adk_web_server.py @@ -601,30 +601,11 @@ def _import_plugin_object(self, qualified_name: str) -> Any: module = importlib.import_module(module_name) return getattr(module, obj_name) - def _setup_runtime_config(self, web_assets_dir: str): - """Sets up the runtime config for the web server.""" - # Read existing runtime config file. - runtime_config_path = os.path.join( - web_assets_dir, "assets", "config", "runtime-config.json" - ) - runtime_config = {} - try: - with open(runtime_config_path, "r") as f: - runtime_config = json.load(f) - except FileNotFoundError: - logger.info( - "File not found: %s. A new runtime config file will be created.", - runtime_config_path, - ) - except json.JSONDecodeError: - logger.warning( - "Failed to decode JSON from %s. The file content will be" - " overwritten.", - runtime_config_path, - ) - runtime_config["backendUrl"] = self.url_prefix if self.url_prefix else "" - - # Set custom logo config. + def _build_runtime_config(self) -> dict[str, Any]: + """Builds the runtime config dict for the dev UI frontend.""" + runtime_config: dict[str, Any] = { + "backendUrl": self.url_prefix if self.url_prefix else "", + } if self.logo_text or self.logo_image_url: if not self.logo_text or not self.logo_image_url: raise ValueError( @@ -635,18 +616,7 @@ def _setup_runtime_config(self, web_assets_dir: str): "text": self.logo_text, "imageUrl": self.logo_image_url, } - elif "logo" in runtime_config: - del runtime_config["logo"] - - # Write the runtime config file. - try: - os.makedirs(os.path.dirname(runtime_config_path), exist_ok=True) - with open(runtime_config_path, "w") as f: - json.dump(runtime_config, f, indent=2) - except IOError as e: - logger.error( - "Failed to write runtime config file %s: %s", runtime_config_path, e - ) + return runtime_config async def _create_session( self, @@ -742,7 +712,7 @@ async def internal_lifespan(app: FastAPI): ], ) if web_assets_dir: - self._setup_runtime_config(web_assets_dir) + self._build_runtime_config() # TODO - register_processors to be removed once --otel_to_cloud is no # longer experimental. @@ -1816,6 +1786,10 @@ async def get_ui_config(): "logo_image_url": self.logo_image_url, } + @app.get("/dev-ui/assets/config/runtime-config.json") + async def get_runtime_config(): + return self._build_runtime_config() + @app.get("/") async def redirect_root_to_dev_ui(): return RedirectResponse(redirect_dev_ui_url) diff --git a/tests/unittests/cli/test_adk_web_server_run_live.py b/tests/unittests/cli/test_adk_web_server_run_live.py index 1c3c42593c..f5c55c11e0 100644 --- a/tests/unittests/cli/test_adk_web_server_run_live.py +++ b/tests/unittests/cli/test_adk_web_server_run_live.py @@ -203,3 +203,70 @@ async def _get_runner_async(_self, _app_name: str): run_config.session_resumption.transparent is expected_session_resumption_transparent ) + + +def _make_server(**kwargs) -> AdkWebServer: + defaults = dict( + agent_loader=_DummyAgentLoader(), + session_service=InMemorySessionService(), + memory_service=types.SimpleNamespace(), + artifact_service=types.SimpleNamespace(), + credential_service=types.SimpleNamespace(), + eval_sets_manager=types.SimpleNamespace(), + eval_set_results_manager=types.SimpleNamespace(), + agents_dir=".", + ) + defaults.update(kwargs) + return AdkWebServer(**defaults) + + +class TestBuildRuntimeConfig: + + def test_default_no_prefix(self): + server = _make_server() + config = server._build_runtime_config() + assert config == {"backendUrl": ""} + + def test_with_url_prefix(self): + server = _make_server(url_prefix="/my-prefix") + config = server._build_runtime_config() + assert config == {"backendUrl": "/my-prefix"} + + def test_with_logo(self): + server = _make_server( + logo_text="My App", + logo_image_url="https://example.com/logo.png", + ) + config = server._build_runtime_config() + assert config == { + "backendUrl": "", + "logo": { + "text": "My App", + "imageUrl": "https://example.com/logo.png", + }, + } + + def test_logo_text_only_raises(self): + server = _make_server(logo_text="My App") + with pytest.raises(ValueError, match="Both --logo-text and --logo-image-url"): + server._build_runtime_config() + + def test_logo_image_url_only_raises(self): + server = _make_server(logo_image_url="https://example.com/logo.png") + with pytest.raises(ValueError, match="Both --logo-text and --logo-image-url"): + server._build_runtime_config() + + +class TestRuntimeConfigEndpoint: + + def test_get_runtime_config_json(self, tmp_path): + server = _make_server(url_prefix="/test-prefix") + app = server.get_fast_api_app( + setup_observer=lambda _observer, _server: None, + tear_down_observer=lambda _observer, _server: None, + web_assets_dir=str(tmp_path), + ) + client = TestClient(app) + resp = client.get("/dev-ui/assets/config/runtime-config.json") + assert resp.status_code == 200 + assert resp.json() == {"backendUrl": "/test-prefix"}