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
199 changes: 176 additions & 23 deletions dev/src/main/java/com/google/adk/plugins/ReplayPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
* <p>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.
*
* <p>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<String, InvocationReplayState> 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
Expand Down Expand Up @@ -169,17 +236,19 @@ private boolean isReplayModeOn(ToolContext toolContext) {
}

private boolean isReplayModeOnFromState(Map<String, Object> sessionState) {
if (!sessionState.containsKey(REPLAY_CONFIG_KEY)) {
return false;
}
Map<String, Object> 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<String, Object> replayConfig(Map<String, Object> sessionState) {
Object config = sessionState.get(REPLAY_CONFIG_KEY);
if (!(config instanceof Map)) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> config = (Map<String, Object>) 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<String, Object> typedConfig = (Map<String, Object>) config;
return typedConfig;
}

private InvocationReplayState getInvocationState(CallbackContext callbackContext) {
Expand All @@ -194,28 +263,32 @@ private void loadInvocationState(InvocationContext invocationContext) {
String invocationId = invocationContext.invocationId();
Map<String, Object> sessionState = invocationContext.session().state();

@SuppressWarnings("unchecked")
Map<String, Object> config = (Map<String, Object>) sessionState.get(REPLAY_CONFIG_KEY);
Map<String, Object> 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);
Expand All @@ -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.
*
* <p>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.
*
* <p>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);

Expand Down
Loading
Loading