From ca6469e3af769149f7c9ef84bc4f937be69fe60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Sobczyk?= Date: Tue, 4 Aug 2026 02:16:56 -0700 Subject: [PATCH] fix(dev): confine ReplayPlugin recordings to a configured replay root Recordings now only load from inside `adk.replay.root` (or `ADK_REPLAY_ROOT`), which defaults to the working directory, so an existing setup whose recordings live elsewhere has to set it. PiperOrigin-RevId: 958891897 --- .../com/google/adk/plugins/ReplayPlugin.java | 203 +++++++++-- .../google/adk/plugins/ReplayPluginTest.java | 325 +++++++++++++++++- .../java/com/google/adk/maven/WebMojo.java | 6 +- 3 files changed, 495 insertions(+), 39 deletions(-) diff --git a/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java b/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java index 89032082c..797eed7f6 100644 --- a/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java +++ b/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java @@ -15,6 +15,9 @@ */ package com.google.adk.plugins; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Strings.isNullOrEmpty; + import com.google.adk.agents.CallbackContext; import com.google.adk.agents.InvocationContext; import com.google.adk.models.LlmRequest; @@ -27,38 +30,107 @@ import com.google.adk.tools.AgentTool; import com.google.adk.tools.BaseTool; import com.google.adk.tools.ToolContext; +import com.google.common.annotations.VisibleForTesting; import com.google.genai.types.Content; import com.google.genai.types.FunctionCall; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Maybe; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Plugin for replaying ADK agent interactions from recordings. */ +/** + * Plugin for replaying ADK agent interactions from recordings. + * + *

The replay case directory comes from the session state, which any caller of the dev server can + * set. Recordings are therefore only loaded from inside a replay root directory that the server + * operator configures: the {@code adk.replay.root} system property, the {@code ADK_REPLAY_ROOT} + * environment variable, or the process working directory when neither is set. Case directories that + * resolve outside the root, through {@code ..} segments, an absolute path or a symlink, are + * rejected. A relative case directory resolves against the root, not against the working directory. + * + *

The working-directory fallback rarely matches where the recordings live, so configure the root + * explicitly; the plugin logs the root it ended up with when it is constructed. + */ public class ReplayPlugin extends BasePlugin { private static final Logger logger = LoggerFactory.getLogger(ReplayPlugin.class); private static final String REPLAY_CONFIG_KEY = "_adk_replay_config"; private static final String RECORDINGS_FILENAME = "generated-recordings.yaml"; + private static final String REPLAY_ROOT_PROPERTY = "adk.replay.root"; + private static final String REPLAY_ROOT_ENV = "ADK_REPLAY_ROOT"; // Track replay state per invocation to support concurrent runs // key: invocation_id -> InvocationReplayState private final Map invocationStates; + // Recordings are only read from inside this directory, never from arbitrary session state paths. + private final Path replayRoot; + public ReplayPlugin() { this("adk_replay"); } public ReplayPlugin(String name) { + this(name, defaultReplayRoot(), configuredReplayRoot() == null); + } + + /** Creates a plugin that only loads recordings from inside {@code replayRoot}. */ + @VisibleForTesting + ReplayPlugin(String name, Path replayRoot) { + this(name, replayRoot, /* usingWorkingDirectory= */ false); + } + + private ReplayPlugin(String name, Path replayRoot, boolean usingWorkingDirectory) { super(name); + this.replayRoot = checkNotNull(replayRoot).toAbsolutePath().normalize(); this.invocationStates = new ConcurrentHashMap<>(); + logReplayRoot(usingWorkingDirectory); + } + + private static Path defaultReplayRoot() { + String configured = configuredReplayRoot(); + return Paths.get(configured != null ? configured : System.getProperty("user.dir", "")); + } + + private static @Nullable String configuredReplayRoot() { + return configuredReplayRoot( + System.getProperty(REPLAY_ROOT_PROPERTY), System.getenv(REPLAY_ROOT_ENV)); + } + + /** Returns the operator-configured replay root, or null when neither knob carries a value. */ + @VisibleForTesting + static @Nullable String configuredReplayRoot( + @Nullable String propertyValue, @Nullable String environmentValue) { + String configured = isNullOrEmpty(propertyValue) ? environmentValue : propertyValue; + return isNullOrEmpty(configured) ? null : configured; + } + + /** Reports the effective replay root once, so a misconfigured root shows up at startup. */ + private void logReplayRoot(boolean usingWorkingDirectory) { + String readable = Files.isDirectory(replayRoot) ? "" : " (not a readable directory)"; + if (usingWorkingDirectory) { + logger.warn( + "Replay recordings are confined to the working directory {}{}, which is rarely where" + + " recordings live. Set -D{} or {} to the directory holding them.", + replayRoot, + readable, + REPLAY_ROOT_PROPERTY, + REPLAY_ROOT_ENV); + } else { + logger.info("Replay recordings are confined to {}{}", replayRoot, readable); + } } @Override @@ -169,17 +241,19 @@ private boolean isReplayModeOn(ToolContext toolContext) { } private boolean isReplayModeOnFromState(Map sessionState) { - if (!sessionState.containsKey(REPLAY_CONFIG_KEY)) { - return false; - } + Map config = replayConfig(sessionState); + return config != null && config.get("dir") != null && config.get("user_message_index") != null; + } + /** Returns the replay config from session state, or null when replay is not configured. */ + private static Map replayConfig(Map sessionState) { + Object config = sessionState.get(REPLAY_CONFIG_KEY); + if (!(config instanceof Map)) { + return null; + } @SuppressWarnings("unchecked") - Map config = (Map) sessionState.get(REPLAY_CONFIG_KEY); - - String caseDir = (String) config.get("dir"); - Integer msgIndex = (Integer) config.get("user_message_index"); - - return caseDir != null && msgIndex != null; + Map typedConfig = (Map) config; + return typedConfig; } private InvocationReplayState getInvocationState(CallbackContext callbackContext) { @@ -194,44 +268,121 @@ private void loadInvocationState(InvocationContext invocationContext) { String invocationId = invocationContext.invocationId(); Map sessionState = invocationContext.session().state(); - @SuppressWarnings("unchecked") - Map config = (Map) sessionState.get(REPLAY_CONFIG_KEY); + Map config = replayConfig(sessionState); if (config == null) { throw new ReplayConfigError("Replay parameters are missing from session state"); } - String caseDir = (String) config.get("dir"); - Integer msgIndex = (Integer) config.get("user_message_index"); - - if (caseDir == null || msgIndex == null) { + Object caseDirValue = config.get("dir"); + Object msgIndexValue = config.get("user_message_index"); + if (caseDirValue == null || msgIndexValue == null) { throw new ReplayConfigError("Replay parameters are missing from session state"); } + if (!(caseDirValue instanceof String)) { + throw new ReplayConfigError( + "Replay parameter 'dir' must be a string, got " + + caseDirValue.getClass().getSimpleName()); + } + String caseDir = (String) caseDirValue; + int msgIndex = userMessageIndex(msgIndexValue); // Load recordings - Path recordingsFile = Paths.get(caseDir, RECORDINGS_FILENAME); - - if (!Files.exists(recordingsFile)) { - throw new ReplayConfigError("Recordings file not found: " + recordingsFile); - } + Path recordingsFile = resolveRecordingsFile(caseDir); - try { - Recordings recordings = RecordingsLoader.load(recordingsFile); + // NOFOLLOW_LINKS: the path was canonical when it was checked, so refuse it if it became a + // symlink in between. + try (InputStream recordingsStream = + Files.newInputStream(recordingsFile, LinkOption.NOFOLLOW_LINKS)) { + Recordings recordings = RecordingsLoader.load(recordingsStream); // Create and store invocation state InvocationReplayState state = new InvocationReplayState(caseDir, msgIndex, recordings); invocationStates.put(invocationId, state); + // The case directory is caller input, so log the counts rather than the path. logger.debug( - "Loaded replay state for invocation {}: case_dir={}, msg_index={}, recordings={}", + "Loaded replay state for invocation {}: msg_index={}, recordings={}", invocationId, - caseDir, msgIndex, recordings.recordings().size()); } catch (IOException e) { + // The parser quotes the offending line, so neither the cause nor the file name is safe to + // carry: the name embeds the caller's case directory. Report the failure shape instead. + throw new ReplayConfigError( + "Failed to load the recordings file under " + + replayRoot + + " (" + + e.getClass().getSimpleName() + + ")"); + } + } + + /** + * Returns the user message index, which YAML and JSON decoders hand over as any numeric type. + * + *

Anything that is not a whole number in range is rejected rather than narrowed, so a bad + * index cannot quietly select the wrong recording. + */ + private static int userMessageIndex(Object value) { + if (!(value instanceof Number)) { throw new ReplayConfigError( - "Failed to load recordings from " + recordingsFile + ": " + e.getMessage(), e); + "Replay parameter 'user_message_index' must be a number, got " + + value.getClass().getSimpleName()); } + double asDouble = ((Number) value).doubleValue(); + if (asDouble != Math.floor(asDouble) || asDouble < 0 || asDouble > Integer.MAX_VALUE) { + throw new ReplayConfigError( + "Replay parameter 'user_message_index' must be a whole number in [0, " + + Integer.MAX_VALUE + + "]"); + } + return ((Number) value).intValue(); + } + + /** + * Resolves the recordings file for a session-supplied case directory, keeping it inside the + * replay root. + * + *

Containment is decided on the canonical path, so neither {@code ..} nor a symlink can leave + * the root however the caller spells it. That canonical path is what is returned, so the caller + * opens exactly what was checked. Messages never echo the case directory, which is caller input. + */ + private Path resolveRecordingsFile(String caseDir) { + Path canonicalRoot; + try { + canonicalRoot = replayRoot.toRealPath(); + } catch (IOException e) { + throw new ReplayConfigError( + "Replay root directory is not readable: " + + replayRoot + + ". Set -D" + + REPLAY_ROOT_PROPERTY + + " to the directory holding the recordings.", + e); + } + + Path recordingsFile; + try { + recordingsFile = replayRoot.resolve(caseDir).normalize().resolve(RECORDINGS_FILENAME); + } catch (InvalidPathException e) { + throw new ReplayConfigError("Replay parameter 'dir' is not a valid path", e); + } + + Path canonicalFile; + try { + canonicalFile = recordingsFile.toRealPath(); + } catch (NoSuchFileException e) { + throw new ReplayConfigError("Recordings file not found under the replay root " + replayRoot); + } catch (IOException e) { + throw new ReplayConfigError("Recordings file is not readable under " + replayRoot, e); + } + if (!canonicalFile.startsWith(canonicalRoot)) { + throw new ReplayConfigError( + "Replay directory resolves outside the replay root " + replayRoot); + } + + return canonicalFile; } private Recording getNextRecordingForAgent(InvocationReplayState state, String agentName) { diff --git a/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java b/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java index 8e89c2567..521f13348 100644 --- a/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java +++ b/dev/src/test/java/com/google/adk/plugins/ReplayPluginTest.java @@ -16,6 +16,7 @@ package com.google.adk.plugins; import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -23,6 +24,7 @@ import com.google.adk.agents.CallbackContext; import com.google.adk.agents.InvocationContext; import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; import com.google.adk.sessions.Session; import com.google.adk.sessions.State; import com.google.adk.tools.BaseTool; @@ -32,10 +34,13 @@ import com.google.genai.types.Content; import com.google.genai.types.Part; import io.reactivex.rxjava3.core.Single; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -50,14 +55,17 @@ class ReplayPluginTest { @TempDir Path tempDir; + private Path replayRoot; private ReplayPlugin plugin; private Session mockSession; private ConcurrentHashMap sessionState; private State state; @BeforeEach - void setUp() { - plugin = new ReplayPlugin(); + void setUp() throws Exception { + // toRealPath: @TempDir can sit behind a symlink, e.g. /var -> /private/var on macOS. + replayRoot = Files.createDirectory(tempDir.resolve("root")).toRealPath(); + plugin = new ReplayPlugin("adk_replay", replayRoot); mockSession = mock(Session.class); sessionState = new ConcurrentHashMap<>(); state = new State(sessionState); @@ -68,7 +76,7 @@ void setUp() { @Test void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws Exception { // Setup: Create a minimal recording file - Path recordingsFile = tempDir.resolve("generated-recordings.yaml"); + Path recordingsFile = replayRoot.resolve("generated-recordings.yaml"); Files.writeString( recordingsFile, """ @@ -92,7 +100,8 @@ void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws // Step 1: Setup replay config sessionState.put( - "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0)); + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); // Step 2: Call beforeRunCallback to load recordings InvocationContext invocationContext = mock(InvocationContext.class); @@ -128,7 +137,7 @@ void beforeModelCallback_withMatchingRecording_returnsRecordedResponse() throws @Test void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception { // Setup: Create recording with different model - Path recordingsFile = tempDir.resolve("generated-recordings.yaml"); + Path recordingsFile = replayRoot.resolve("generated-recordings.yaml"); Files.writeString( recordingsFile, """ @@ -147,7 +156,8 @@ void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception { // Step 1: Setup replay config sessionState.put( - "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0)); + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); // Step 2: Load recordings InvocationContext invocationContext = mock(InvocationContext.class); @@ -179,7 +189,7 @@ void beforeModelCallback_requestMismatch_returnsEmpty() throws Exception { @Test void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws Exception { // Setup: Create recording with tool call - Path recordingsFile = tempDir.resolve("generated-recordings.yaml"); + Path recordingsFile = replayRoot.resolve("generated-recordings.yaml"); Files.writeString( recordingsFile, """ @@ -202,7 +212,8 @@ void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws E // Step 1: Setup replay config sessionState.put( - "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0)); + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); // Step 2: Load recordings InvocationContext invocationContext = mock(InvocationContext.class); @@ -234,7 +245,7 @@ void beforeToolCallback_withMatchingRecording_returnsRecordedResponse() throws E @Test void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception { // Setup: Create recording - Path recordingsFile = tempDir.resolve("generated-recordings.yaml"); + Path recordingsFile = replayRoot.resolve("generated-recordings.yaml"); Files.writeString( recordingsFile, """ @@ -251,7 +262,8 @@ void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception { // Step 1: Setup replay config sessionState.put( - "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0)); + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); // Step 2: Load recordings InvocationContext invocationContext = mock(InvocationContext.class); @@ -279,7 +291,7 @@ void beforeToolCallback_toolNameMismatch_returnsEmpty() throws Exception { @Test void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception { // Setup: Create recording - Path recordingsFile = tempDir.resolve("generated-recordings.yaml"); + Path recordingsFile = replayRoot.resolve("generated-recordings.yaml"); Files.writeString( recordingsFile, """ @@ -296,7 +308,8 @@ void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception { // Step 1: Setup replay config sessionState.put( - "_adk_replay_config", ImmutableMap.of("dir", tempDir.toString(), "user_message_index", 0)); + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); // Step 2: Load recordings InvocationContext invocationContext = mock(InvocationContext.class); @@ -321,4 +334,292 @@ void beforeToolCallback_toolArgsMismatch_returnsEmpty() throws Exception { .blockingGet(); assertThat(result).isNull(); } + + @Test + void beforeRunCallback_relativeCaseDirInsideRoot_loadsRecordings() throws Exception { + Path caseDir = Files.createDirectory(replayRoot.resolve("case")); + Files.writeString(caseDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0)); + + plugin.beforeRunCallback(newInvocationContext()).blockingGet(); + + assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response"); + } + + @Test + void beforeRunCallback_symlinkedCaseDirInsideRoot_loadsRecordings() throws Exception { + Path caseDir = Files.createDirectory(replayRoot.resolve("actual_case")); + Files.writeString(caseDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + createSymbolicLinkOrSkip(replayRoot.resolve("case"), caseDir); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0)); + + plugin.beforeRunCallback(newInvocationContext()).blockingGet(); + + assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response"); + } + + @Test + void beforeRunCallback_caseDirTraversesOutsideRoot_throws() throws Exception { + Path outsideDir = Files.createDirectory(tempDir.resolve("outside")); + Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put( + "_adk_replay_config", ImmutableMap.of("dir", "../outside", "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("resolves outside the replay root"); + } + + @Test + void beforeRunCallback_absoluteCaseDirOutsideRoot_throws() throws Exception { + Path outsideDir = Files.createDirectory(tempDir.resolve("outside")); + Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put( + "_adk_replay_config", + ImmutableMap.of("dir", outsideDir.toString(), "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("resolves outside the replay root"); + } + + @Test + void beforeRunCallback_symlinkedCaseDirOutsideRoot_throws() throws Exception { + Path outsideDir = Files.createDirectory(tempDir.resolve("outside")); + Files.writeString(outsideDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + createSymbolicLinkOrSkip(replayRoot.resolve("case"), outsideDir); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("resolves outside the replay root"); + } + + @Test + void beforeRunCallback_symlinkedRecordingsFileOutsideRoot_throws() throws Exception { + Path outsideDir = Files.createDirectory(tempDir.resolve("outside")); + Path outsideFile = outsideDir.resolve("generated-recordings.yaml"); + Files.writeString(outsideFile, MINIMAL_RECORDINGS); + Path caseDir = Files.createDirectory(replayRoot.resolve("case")); + createSymbolicLinkOrSkip(caseDir.resolve("generated-recordings.yaml"), outsideFile); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("resolves outside the replay root"); + } + + @Test + void beforeRunCallback_caseDirIsSiblingSharingRootPrefix_throws() throws Exception { + // "root" is a lexical prefix of "rootsibling", so a string comparison would let this through. + Path siblingDir = Files.createDirectory(tempDir.resolve("rootsibling")); + Files.writeString(siblingDir.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put( + "_adk_replay_config", + ImmutableMap.of("dir", siblingDir.toString(), "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("resolves outside the replay root"); + } + + @Test + void beforeRunCallback_missingRecordingsFileInsideRoot_throws() { + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", "case", "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("Recordings file not found"); + } + + @Test + void beforeRunCallback_replayRootFromSystemProperty_loadsRecordings() throws Exception { + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + String previous = System.getProperty("adk.replay.root"); + System.setProperty("adk.replay.root", replayRoot.toString()); + try { + ReplayPlugin pluginFromProperty = new ReplayPlugin(); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 0)); + + pluginFromProperty.beforeRunCallback(newInvocationContext()).blockingGet(); + + assertThat(replayedResponseText(pluginFromProperty)).isEqualTo("Recorded response"); + } finally { + if (previous == null) { + System.clearProperty("adk.replay.root"); + } else { + System.setProperty("adk.replay.root", previous); + } + } + } + + @Test + void beforeRunCallback_nonMapReplayConfig_leavesReplayOff() { + sessionState.put("_adk_replay_config", "/etc/passwd"); + + plugin.beforeRunCallback(newInvocationContext()).blockingGet(); + + // Replay stays off, so the model call falls through instead of hitting missing replay state. + assertThat(replayedResponse(plugin)).isNull(); + } + + @Test + void beforeRunCallback_userMessageIndexNotANumber_throws() throws Exception { + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put( + "_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", "zero")); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("'user_message_index' must be a number"); + } + + @Test + void beforeRunCallback_userMessageIndexNotAWholeNumber_throws() throws Exception { + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 1.5)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("must be a whole number"); + } + + @Test + void beforeRunCallback_userMessageIndexAsLong_loadsRecordings() throws Exception { + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", ".", "user_message_index", 0L)); + + plugin.beforeRunCallback(newInvocationContext()).blockingGet(); + + assertThat(replayedResponseText(plugin)).isEqualTo("Recorded response"); + } + + @Test + void beforeRunCallback_caseDirNotAString_throws() throws Exception { + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put("_adk_replay_config", ImmutableMap.of("dir", 1, "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> plugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains("'dir' must be a string"); + } + + @Test + void beforeRunCallback_rootReachedThroughSymlink_loadsRecordings() throws Exception { + // The conformance dev server configures its root through a symlink, so both spellings have to + // work: the symlinked root, and the canonical one the caller may send back. + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + Path linkedRoot = tempDir.resolve("linked_root"); + createSymbolicLinkOrSkip(linkedRoot, replayRoot); + ReplayPlugin linkedPlugin = new ReplayPlugin("adk_replay", linkedRoot); + + sessionState.put( + "_adk_replay_config", + ImmutableMap.of("dir", linkedRoot.toString(), "user_message_index", 0)); + linkedPlugin.beforeRunCallback(newInvocationContext()).blockingGet(); + assertThat(replayedResponseText(linkedPlugin)).isEqualTo("Recorded response"); + + sessionState.put( + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); + linkedPlugin.beforeRunCallback(newInvocationContext()).blockingGet(); + assertThat(replayedResponseText(linkedPlugin)).isEqualTo("Recorded response"); + } + + @Test + void beforeRunCallback_workingDirectoryRoot_rejectsCaseDirOutsideIt() throws Exception { + // The root the plugin falls back to when neither knob is set. Driven through the constructor + // so the test does not depend on the environment it runs in. + ReplayPlugin workingDirPlugin = new ReplayPlugin("adk_replay", Paths.get("")); + Files.writeString(replayRoot.resolve("generated-recordings.yaml"), MINIMAL_RECORDINGS); + sessionState.put( + "_adk_replay_config", + ImmutableMap.of("dir", replayRoot.toString(), "user_message_index", 0)); + + ReplayConfigError error = + assertThrows( + ReplayConfigError.class, + () -> workingDirPlugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains(Paths.get("").toAbsolutePath().toString()); + } + + @Test + void configuredReplayRoot_prefersPropertyOverEnvironment() { + assertThat(ReplayPlugin.configuredReplayRoot("/from-property", "/from-env")) + .isEqualTo("/from-property"); + assertThat(ReplayPlugin.configuredReplayRoot(null, "/from-env")).isEqualTo("/from-env"); + assertThat(ReplayPlugin.configuredReplayRoot("", "/from-env")).isEqualTo("/from-env"); + assertThat(ReplayPlugin.configuredReplayRoot(null, null)).isNull(); + assertThat(ReplayPlugin.configuredReplayRoot("", "")).isNull(); + } + + private InvocationContext newInvocationContext() { + InvocationContext invocationContext = mock(InvocationContext.class); + when(invocationContext.session()).thenReturn(mockSession); + when(invocationContext.invocationId()).thenReturn("test-invocation"); + return invocationContext; + } + + /** + * Windows without developer mode refuses symlinks with an IOException, not the documented one. + */ + private static void createSymbolicLinkOrSkip(Path link, Path target) { + try { + Files.createSymbolicLink(link, target); + } catch (UnsupportedOperationException | IOException e) { + Assumptions.abort("File system does not support symlinks: " + e.getMessage()); + } + } + + /** Runs the model callback against the loaded recordings, or null when replay is off. */ + private LlmResponse replayedResponse(ReplayPlugin plugin) { + CallbackContext callbackContext = mock(CallbackContext.class); + when(callbackContext.state()).thenReturn(state); + when(callbackContext.invocationId()).thenReturn("test-invocation"); + when(callbackContext.agentName()).thenReturn("test_agent"); + return plugin + .beforeModelCallback(callbackContext, LlmRequest.builder().model("gemini-2.0-flash")) + .blockingGet(); + } + + private String replayedResponseText(ReplayPlugin plugin) { + LlmResponse response = replayedResponse(plugin); + assertThat(response).isNotNull(); + assertThat(response.content()).isPresent(); + return response.content().get().text(); + } + + private static final String MINIMAL_RECORDINGS = + """ + recordings: + - user_message_index: 0 + agent_index: 0 + agent_name: "test_agent" + llm_recording: + llm_request: + model: "gemini-2.0-flash" + llm_responses: + - content: + role: "model" + parts: + - text: "Recorded response" + """; } diff --git a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java index 78935155a..6065de0f2 100644 --- a/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java +++ b/maven_plugin/src/main/java/com/google/adk/maven/WebMojo.java @@ -201,9 +201,13 @@ public class WebMojo extends AbstractMojo { *

Example: * *

{@code
-   * mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin
+   * mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin -Dadk.replay.root=/path/to/conformance
    * mvn google-adk:web -Dagents=... -DextraPlugins=com.google.adk.plugins.ReplayPlugin,com.example.CustomPlugin
    * }
+ * + *

{@code ReplayPlugin} takes its case directory from the request, so it only loads recordings + * from inside {@code -Dadk.replay.root} (the working directory when that is unset). Point it at + * the directory holding the conformance cases, otherwise every replay request is rejected. */ @Parameter(property = "extraPlugins") private String extraPlugins;