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..6654fa58f 100644 --- a/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java +++ b/dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java @@ -32,7 +32,11 @@ 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; @@ -42,23 +46,86 @@ 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}. + * + * @param name the plugin name + * @param replayRoot the directory that every replay case directory must resolve inside + */ + public ReplayPlugin(String name, Path replayRoot) { + this(name, replayRoot, /* usingWorkingDirectory= */ false); + } + + private ReplayPlugin(String name, Path replayRoot, boolean usingWorkingDirectory) { super(name); + this.replayRoot = 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", "")); + } + + /** Returns the operator-configured replay root, or null when neither knob is set. */ + private static String configuredReplayRoot() { + String configured = System.getProperty(REPLAY_ROOT_PROPERTY); + if (configured == null || configured.isEmpty()) { + configured = System.getenv(REPLAY_ROOT_ENV); + } + return configured == null || configured.isEmpty() ? 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 +236,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,28 +263,32 @@ 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); + Path recordingsFile = resolveRecordingsFile(caseDir); - if (!Files.exists(recordingsFile)) { - throw new ReplayConfigError("Recordings file not found: " + recordingsFile); - } - - 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); @@ -234,6 +307,86 @@ private void loadInvocationState(InvocationContext invocationContext) { } } + /** + * 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( + "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 + + "], got " + + value); + } + return ((Number) value).intValue(); + } + + /** + * Resolves the recordings file for a session-supplied case directory, keeping it inside the + * replay root. + * + *

Containment is checked twice: once lexically, so an obvious escape never touches the file + * system, and once on the canonical path, so a symlink cannot point out of the root. The + * canonical path is returned, so the caller opens exactly what was checked. + */ + 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("Invalid replay directory: " + caseDir, e); + } + // When the root is itself a symlink the caller may spell it either way, so a lexical miss is + // conclusive only outside both spellings. The canonical check below is the one that decides. + if (!recordingsFile.startsWith(replayRoot) && !recordingsFile.startsWith(canonicalRoot)) { + throw new ReplayConfigError( + "Replay directory '" + + caseDir + + "' resolves outside the replay root " + + replayRoot + + " (-D" + + REPLAY_ROOT_PROPERTY + + ")"); + } + + Path canonicalFile; + try { + canonicalFile = recordingsFile.toRealPath(); + } catch (NoSuchFileException e) { + throw new ReplayConfigError("Recordings file not found: " + recordingsFile, e); + } catch (IOException e) { + throw new ReplayConfigError("Failed to read recordings file: " + recordingsFile, e); + } + if (!canonicalFile.startsWith(canonicalRoot)) { + throw new ReplayConfigError( + "Replay directory '" + caseDir + "' links outside the replay root " + canonicalRoot); + } + + return canonicalFile; + } + private Recording getNextRecordingForAgent(InvocationReplayState state, String agentName) { int currentAgentIndex = state.getAgentReplayIndex(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..7112f8a19 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,291 @@ 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("links 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("links 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_noConfiguredRoot_confinesToWorkingDirectory() throws Exception { + String previous = System.getProperty("adk.replay.root"); + System.clearProperty("adk.replay.root"); + try { + // Neither knob set, so the root is the working directory. The temp directory is outside it, + // and the rejection names the property the operator has to set. + ReplayPlugin defaultPlugin = new ReplayPlugin(); + 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, + () -> defaultPlugin.beforeRunCallback(newInvocationContext()).blockingGet()); + assertThat(error).hasMessageThat().contains(Paths.get("").toAbsolutePath().toString()); + assertThat(error).hasMessageThat().contains("adk.replay.root"); + } finally { + if (previous != null) { + System.setProperty("adk.replay.root", previous); + } + } + } + + 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;