Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,36 @@ The UI and its assets are served under `/dev-ui/`, and both `/` and `/dev-ui`
redirect there, keeping the query string. The assets are not served from the
origin root: `/adk_favicon.svg` and the like return 404, and only the `/dev-ui/`
form resolves.

## Behind a reverse proxy

When a gateway publishes this server under a path prefix and strips it, tell the
server the address browsers actually reach it on:

```properties
adk.web.backend-url=https://gateway.example.com/my-app
```

That one value does both halves: the entry redirect carries the prefix, and the
UI's own API calls go back through it. Nothing has to be forwarded by the proxy,
and nothing is read from the request.

It must be an absolute URL. The UI reads a value without a scheme as the host of
its live/websocket connection, so a bare `/my-app` makes that socket dial a host
named `my-app`.

Include any `server.servlet.context-path` in the value: in the redirect it
replaces the context path rather than stacking on it.

Leave it unset and nothing changes: the redirect is unprefixed and the bundled
`backendUrl` is served as it always was. A deployment that already restores the
prefix with Spring's own `server.forward-headers-strategy=framework` keeps
working that way; this property takes precedence over it for the redirect's
path.

Turning that Spring setting on is a decision to trust forwarded headers, and
both the standard `Forwarded` header and the `X-Forwarded-*` family are supplied
by the client unless something overwrites them. The proxy at the edge has to
strip or overwrite both kinds arriving from outside, or a caller can tell the
server it was reached somewhere it was not. Setting `adk.web.backend-url` does
not require that setting at all.
40 changes: 32 additions & 8 deletions dev/src/main/java/com/google/adk/web/AdkWebServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
import com.google.adk.memory.InMemoryMemoryService;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.InMemorySessionService;
import com.google.adk.web.config.BackendUrl;
import com.google.adk.web.config.DevUiAssets;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -52,6 +54,20 @@
@Value("${adk.web.ui.dir:#{null}}")
private String webUiDir;

@Value("${adk.web.backend-url:}")
private String backendUrlProperty;

private @Nullable BackendUrl parsedBackendUrl;

/** Parsed here once, and shared, so this and the runtime-config endpoint cannot diverge. */
@Bean
public synchronized BackendUrl backendUrl() {
if (parsedBackendUrl == null) {
parsedBackendUrl = BackendUrl.from(backendUrlProperty);
}
return parsedBackendUrl;
}

@Bean
public BaseSessionService sessionService() {
// TODO: Add logic to select service based on config (e.g., DB URL)
Expand Down Expand Up @@ -104,9 +120,9 @@
* @return A configured MappingJackson2HttpMessageConverter.
*/
@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 123 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal
ObjectMapper objectMapper) {
return new MappingJackson2HttpMessageConverter(objectMapper);

Check warning on line 125 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 125 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 125 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal
}

/**
Expand All @@ -125,17 +141,25 @@
}

/**
* Configures simple automated controllers: "/" and "/dev-ui" both redirect to "/dev-ui/", which
* forwards to the UI's index.html. The trailing slash is required: index.html declares a {@code
* <base href="./">}, so served from "/dev-ui" the app resolves its own router path to "dev-ui"
* and matches none of its routes. The query string is carried across because the UI selects its
* agent from {@code ?app=} and the sample READMEs send users to the slashless "/dev-ui", so a
* redirect that dropped it would silently ignore the selection.
* Configures simple automated controllers: "/" and "/dev-ui" both redirect to the UI, at {@code
* adk.web.backend-url}'s path when that is set, and it forwards to index.html. The trailing slash
* is required: index.html declares a {@code <base href="./">}, so served from "/dev-ui" the app
* resolves its own router path to "dev-ui" and matches none of its routes. The query string is
* carried across because the UI selects its agent from {@code ?app=}.
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addRedirectViewController("/", "/dev-ui/").setKeepQueryParams(true);
registry.addRedirectViewController("/dev-ui", "/dev-ui/").setKeepQueryParams(true);
String prefix = backendUrl().pathPrefix();
// The configured value is the public base, so do not stack the context path on it.
boolean contextRelative = prefix.isEmpty();
registry
.addRedirectViewController("/", prefix + "/dev-ui/")
.setKeepQueryParams(true)
.setContextRelative(contextRelative);
registry
.addRedirectViewController("/dev-ui", prefix + "/dev-ui/")
.setKeepQueryParams(true)
.setContextRelative(contextRelative);
registry.addViewController("/dev-ui/").setViewName("forward:/dev-ui/index.html");
}

Expand Down
107 changes: 107 additions & 0 deletions dev/src/main/java/com/google/adk/web/config/BackendUrl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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.
*/

package com.google.adk.web.config;

import com.google.common.base.CharMatcher;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The address browsers reach this server on, from {@code adk.web.backend-url}. Parsed in one place,
* so the dev UI's config and its entry redirect read the same setting the same way.
*/
public final class BackendUrl {

private static final Logger log = LoggerFactory.getLogger(BackendUrl.class);

/** The UI strips the scheme case-sensitively, so an upper-case one is not usable. */
private static final Pattern ABSOLUTE_URL = Pattern.compile("^https?://.+");

private static final BackendUrl UNSET = new BackendUrl("", "");

private final String value;
private final String pathPrefix;

private BackendUrl(String value, String pathPrefix) {
this.value = value;
this.pathPrefix = pathPrefix;
}

/** Interprets {@code configured}, warning once if it is not something the UI can use. */
public static BackendUrl from(@Nullable String configured) {
if (configured == null || configured.trim().isEmpty()) {
return UNSET;
}
String trimmed = configured.trim();
// A trailing slash would double up: the UI appends paths that already start with one.
String normalized = CharMatcher.is('/').trimTrailingFrom(trimmed);
String path = pathOf(normalized);
if (ABSOLUTE_URL.matcher(normalized).matches() && path != null) {
return new BackendUrl(normalized, path);
}
log.warn(
"adk.web.backend-url should be an absolute URL, but is \"{}\". The dev UI reads a value"
+ " without a lower-case http:// or https:// scheme as the host of its live/websocket"
+ " connection.",
trimmed);
// Served as configured: an explicit value is never silently discarded.
return new BackendUrl(trimmed, path == null ? "" : path);
}

/** What the dev UI's runtime config reports, or empty when unset. */
public String value() {
return value;
}

/**
* The path a gateway strips, which the entry redirect has to carry, or empty when there is none.
* Percent-encoding is kept, because this goes into a {@code Location} header.
*/
public String pathPrefix() {
return pathPrefix;
}

/**
* The URL's path, or null when it is absent, relative, or carries something the UI cannot use.
*/
private static @Nullable String pathOf(String url) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
return null;
}
// The UI appends onto the whole value, so a query, fragment or userinfo would end up spliced
// into the middle of every request it builds.
if (uri.getRawQuery() != null || uri.getRawFragment() != null || uri.getRawUserInfo() != null) {
return null;
}
String raw = uri.getRawPath();
if (raw == null || raw.isEmpty()) {
return "";
}
if (!raw.startsWith("/")) {
return null;
}
// "//host" in a Location is protocol-relative, so a browser would read it as a host.
return CharMatcher.is('/').trimTrailingFrom(raw.replaceAll("^/+", "/"));
}
}
13 changes: 11 additions & 2 deletions dev/src/main/java/com/google/adk/web/config/DevUiAssets.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@
import org.springframework.core.io.ResourceLoader;

/**
* Where the dev UI's static assets live. Normalizes {@code adk.web.ui.dir} into a resource
* location, so callers that need it do not each do it differently.
* Where the dev UI's static assets live. Shared so the resource handler and the runtime-config
* endpoint resolve the same location; normalizing {@code adk.web.ui.dir} separately in each would
* diverge silently.
*/
public final class DevUiAssets {

/** The runtime config, relative to the asset root. */
public static final String RUNTIME_CONFIG_PATH = "assets/config/runtime-config.json";

private static final String CLASSPATH_ROOT = ResourceLoader.CLASSPATH_URL_PREFIX + "/browser/";

/**
Expand All @@ -42,5 +46,10 @@ public static String assetRoot(@Nullable String webUiDir) {
return location.endsWith("/") ? location : location + "/";
}

/** The location of a single asset, given relative to the asset root. */
public static String assetLocation(@Nullable String webUiDir, String relativePath) {
return assetRoot(webUiDir) + relativePath;
}

private DevUiAssets() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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.
*/

package com.google.adk.web.controller;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.web.config.BackendUrl;
import com.google.adk.web.config.DevUiAssets;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Serves the dev UI's runtime configuration, shadowing the copy bundled in the static assets so
* {@code adk.web.backend-url} can point the UI at the address browsers reach this server on. The
* bundled document is merged rather than replaced, so keys the UI gains in a later bundle survive.
*/
@RestController
public class RuntimeConfigController {

private static final Logger log = LoggerFactory.getLogger(RuntimeConfigController.class);

private final ResourceLoader resourceLoader;
private final ObjectMapper objectMapper;
private final @Nullable String webUiDir;
private final String backendUrl;

/** Reads the bundled config through {@code resourceLoader}, or from {@code webUiDir} if set. */
@Autowired
public RuntimeConfigController(
ResourceLoader resourceLoader,
ObjectMapper objectMapper,
@Value("${adk.web.ui.dir:#{null}}") @Nullable String webUiDir,
BackendUrl backendUrl) {
this.resourceLoader = resourceLoader;
this.objectMapper = objectMapper;
this.webUiDir = webUiDir;
this.backendUrl = backendUrl.value();
}

/** Serves the bundled config with {@code backendUrl} taken from configuration when set. */
@GetMapping(
value = "/dev-ui/" + DevUiAssets.RUNTIME_CONFIG_PATH,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> runtimeConfig() {
Map<String, Object> config = readBundledConfig();
// Unset leaves a value the bundled document already carries, which is what used to be served.
if (backendUrl.isEmpty()) {
config.putIfAbsent("backendUrl", "");
} else {
config.put("backendUrl", backendUrl);
}
// The bundled document can change on disk under adk.web.ui.dir, so do not let it be cached.
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(config);
}

/**
* The bundled config, or an empty document when it is absent or unreadable. A dev UI that cannot
* read its own config is worse than one whose extra keys defaulted, so this never fails the
* request.
*/
private Map<String, Object> readBundledConfig() {
Resource resource =
resourceLoader.getResource(
DevUiAssets.assetLocation(webUiDir, DevUiAssets.RUNTIME_CONFIG_PATH));
if (!resource.exists()) {
log.debug("No bundled dev UI runtime config at {}; serving backendUrl only.", resource);
return new LinkedHashMap<>();
}
try (InputStream in = resource.getInputStream()) {
Map<String, Object> parsed = objectMapper.readValue(in, new TypeReference<>() {});
return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed);
} catch (IOException e) {
log.warn("Could not read the bundled dev UI runtime config at {}.", resource, e);
return new LinkedHashMap<>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
*
* <p>The filter honours the standard {@code Forwarded} header as well as {@code X-Forwarded-*}, so
* both are covered.
*
* <p>This is the path taken when {@code adk.web.backend-url} is unset. Setting it supplies the
* prefix directly and the forwarded one is ignored, though the host and scheme still come from
* these headers while the filter is enabled.
*/
@SpringBootTest(properties = "server.forward-headers-strategy=framework")
@AutoConfigureMockMvc
Expand Down
Loading
Loading