Skip to content

Commit 0c4fd2c

Browse files
sherryfoxcopybara-github
authored andcommitted
feat: allow disabling the ADK Development UI with adk.web.ui.enabled
Set adk.web.ui.enabled to false, as a system property or in the application config, to leave the Development UI routes unmounted. It stays mounted by default. PiperOrigin-RevId: 968449879
1 parent 7a3818b commit 0c4fd2c

3 files changed

Lines changed: 217 additions & 2 deletions

File tree

webserver/src/jvmMain/kotlin/com/google/adk/kt/webserver/AdkWebServer.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.google.adk.kt.webserver.loaders.AgentLoader
3030
import com.google.adk.kt.webserver.models.VersionInfo
3131
import com.google.adk.kt.webserver.routes.appRoutes
3232
import com.google.adk.kt.webserver.routes.artifactRoutes
33+
import com.google.adk.kt.webserver.routes.isWebUiEnabled
3334
import com.google.adk.kt.webserver.routes.runRoutes
3435
import com.google.adk.kt.webserver.routes.sessionRoutes
3536
import com.google.adk.kt.webserver.routes.staticRoutes
@@ -57,9 +58,13 @@ import org.slf4j.event.Level
5758
* Embedded Ktor server exposing the ADK dev/web API.
5859
*
5960
* [start] and [stop] are safe to call from different threads; a [stop] arriving while [start] is
60-
* still binding aborts it. A failed [start] leaves the engine recorded, so call [stop] before
61+
* still binding aborts it, and a failed [start] leaves the engine recorded, so call [stop] before
6162
* retrying.
6263
*
64+
* Set `adk.web.ui.enabled` to `false`, as a system property or in the application config, to leave
65+
* the Development UI unmounted; only `true` and `false` count, and any other value is ignored with
66+
* a warning.
67+
*
6368
* @property captureMessageContent When true, the server records prompt/response content into
6469
* telemetry spans so the Dev UI trace view can display it. This may capture PII and increase span
6570
* size, so it defaults to false; enable it only for local development.
@@ -183,6 +188,8 @@ fun Application.adkModule(
183188
graphRoutes(agentLoader, sessionService)
184189
runRoutes(agentLoader, sessionService, artifactService, plugins)
185190
sessionRoutes(sessionService)
186-
staticRoutes(this@adkModule)
191+
if (this@adkModule.isWebUiEnabled(default = true)) {
192+
staticRoutes(this@adkModule)
193+
}
187194
}
188195
}

webserver/src/jvmMain/kotlin/com/google/adk/kt/webserver/routes/StaticRoutes.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ import org.slf4j.LoggerFactory
3232

3333
private val logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass())
3434

35+
/** Property that decides whether the Development UI is served at all. */
36+
internal const val WEB_UI_ENABLED_PROPERTY = "adk.web.ui.enabled"
37+
38+
/**
39+
* Whether to mount the Development UI, from the `adk.web.ui.enabled` system property, else the
40+
* application config, else [default]. A value that is blank or not a boolean counts as unset, so a
41+
* mistyped system property cannot mask a setting in the config.
42+
*/
43+
internal fun Application.isWebUiEnabled(default: Boolean): Boolean =
44+
webUiSetting(System.getProperty(WEB_UI_ENABLED_PROPERTY))
45+
?: webUiSetting(environment.config.propertyOrNull(WEB_UI_ENABLED_PROPERTY)?.getString())
46+
?: default
47+
48+
/** Parses one configured value, returning null when it is absent, blank or not a boolean. */
49+
private fun webUiSetting(raw: String?): Boolean? {
50+
val value = raw?.trim()?.ifEmpty { null } ?: return null
51+
val parsed = value.lowercase().toBooleanStrictOrNull()
52+
if (parsed == null) {
53+
logger.warn("Ignoring a non-boolean value of {}: {}", WEB_UI_ENABLED_PROPERTY, value)
54+
}
55+
return parsed
56+
}
57+
3558
fun Route.staticRoutes(application: Application) {
3659
var webUiDir =
3760
System.getProperty("adk.web.ui.dir")
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.kt.webserver
18+
19+
import com.google.adk.kt.webserver.routes.WEB_UI_ENABLED_PROPERTY
20+
import com.google.adk.kt.webserver.routes.isWebUiEnabled
21+
import com.google.adk.kt.webserver.telemetry.ApiServerSpanExporter
22+
import com.google.common.truth.Truth.assertThat
23+
import io.ktor.client.request.get
24+
import io.ktor.http.HttpStatusCode
25+
import io.ktor.server.config.MapApplicationConfig
26+
import io.ktor.server.testing.ApplicationTestBuilder
27+
import io.ktor.server.testing.testApplication
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import org.junit.runners.JUnit4
31+
32+
/**
33+
* The `adk.web.ui.enabled` property decides whether the Development UI routes are mounted.
34+
*
35+
* `/` is the discriminator: `staticRoutes` registers its redirect only when the UI is mounted.
36+
*/
37+
@RunWith(JUnit4::class)
38+
class WebUiToggleTest {
39+
private val sessionService = FakeSessionService()
40+
private val artifactService = FakeArtifactService()
41+
private val agentLoader = FakeAgentLoader()
42+
43+
@Test
44+
fun webUi_unset_isMounted() =
45+
withWebUiProperty(null) {
46+
testApplication {
47+
installAdk()
48+
49+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.Found)
50+
assertThat(client.get(DEV_UI_INDEX).status).isEqualTo(HttpStatusCode.OK)
51+
}
52+
}
53+
54+
@Test
55+
fun webUi_disabled_isNotMounted() =
56+
withWebUiProperty("false") {
57+
testApplication {
58+
installAdk()
59+
60+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.NotFound)
61+
assertThat(client.get(DEV_UI_INDEX).status).isEqualTo(HttpStatusCode.NotFound)
62+
// The contract endpoints are untouched by the toggle.
63+
assertThat(client.get("/health").status).isEqualTo(HttpStatusCode.OK)
64+
}
65+
}
66+
67+
@Test
68+
fun webUi_disabledInMixedCase_isNotMounted() =
69+
withWebUiProperty("FALSE") {
70+
testApplication {
71+
installAdk()
72+
73+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.NotFound)
74+
}
75+
}
76+
77+
@Test
78+
fun webUi_nonBooleanValue_fallsBackToDefault() =
79+
withWebUiProperty("perhaps") {
80+
testApplication {
81+
installAdk()
82+
83+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.Found)
84+
}
85+
}
86+
87+
@Test
88+
fun webUi_disabledInConfig_isNotMounted() =
89+
withWebUiProperty(null) {
90+
testApplication {
91+
environment { config = MapApplicationConfig(WEB_UI_ENABLED_PROPERTY to "false") }
92+
installAdk()
93+
94+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.NotFound)
95+
}
96+
}
97+
98+
@Test
99+
fun webUi_nonBooleanConfigValue_fallsBackToDefault() =
100+
withWebUiProperty(null) {
101+
testApplication {
102+
environment { config = MapApplicationConfig(WEB_UI_ENABLED_PROPERTY to "perhaps") }
103+
installAdk()
104+
105+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.Found)
106+
}
107+
}
108+
109+
@Test
110+
fun webUi_blankProperty_fallsThroughToConfig() =
111+
withWebUiProperty(" ") {
112+
testApplication {
113+
environment { config = MapApplicationConfig(WEB_UI_ENABLED_PROPERTY to "false") }
114+
installAdk()
115+
116+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.NotFound)
117+
}
118+
}
119+
120+
@Test
121+
fun webUi_nonBooleanProperty_fallsThroughToConfig() =
122+
withWebUiProperty("no") {
123+
testApplication {
124+
environment { config = MapApplicationConfig(WEB_UI_ENABLED_PROPERTY to "false") }
125+
installAdk()
126+
127+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.NotFound)
128+
}
129+
}
130+
131+
@Test
132+
fun webUi_property_winsOverConfig() =
133+
withWebUiProperty("true") {
134+
testApplication {
135+
environment { config = MapApplicationConfig(WEB_UI_ENABLED_PROPERTY to "false") }
136+
installAdk()
137+
138+
assertThat(rootStatus()).isEqualTo(HttpStatusCode.Found)
139+
}
140+
}
141+
142+
@Test
143+
fun webUi_unsetEverywhere_usesCallerDefault() =
144+
withWebUiProperty(null) {
145+
testApplication {
146+
application {
147+
assertThat(isWebUiEnabled(default = false)).isFalse()
148+
assertThat(isWebUiEnabled(default = true)).isTrue()
149+
}
150+
// testApplication builds the Application lazily, so force it.
151+
client.get("/")
152+
}
153+
}
154+
155+
private fun ApplicationTestBuilder.installAdk() {
156+
application { adkModule(sessionService, artifactService, agentLoader, ApiServerSpanExporter()) }
157+
}
158+
159+
/** Status of `/` without following the redirect, so the redirect itself is what is asserted. */
160+
private suspend fun ApplicationTestBuilder.rootStatus(): HttpStatusCode =
161+
createClient { followRedirects = false }.get("/").status
162+
163+
/** Runs [body] with `adk.web.ui.enabled` set to [value], or unset when it is null. */
164+
private fun withWebUiProperty(value: String?, body: () -> Unit) {
165+
val previous: String? = System.getProperty(WEB_UI_ENABLED_PROPERTY)
166+
if (value == null) {
167+
System.clearProperty(WEB_UI_ENABLED_PROPERTY)
168+
} else {
169+
System.setProperty(WEB_UI_ENABLED_PROPERTY, value)
170+
}
171+
try {
172+
body()
173+
} finally {
174+
if (previous == null) {
175+
System.clearProperty(WEB_UI_ENABLED_PROPERTY)
176+
} else {
177+
System.setProperty(WEB_UI_ENABLED_PROPERTY, previous)
178+
}
179+
}
180+
}
181+
182+
private companion object {
183+
const val DEV_UI_INDEX = "/dev-ui/index.html"
184+
}
185+
}

0 commit comments

Comments
 (0)