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
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -105,6 +106,18 @@ public class WorkspaceManager implements AutoCloseable {
private static final TypeReference<Map<String, TaskRecord>> TASK_MAP_TYPE =
new TypeReference<>() {};

/**
* Characters that Windows/NTFS reserves inside a single path segment. Ids such as the session
* id can legitimately contain a colon (for example {@code agent:<id>:main:<uuid>}), and using
* one verbatim in a file name turns path construction into an {@link
* java.nio.file.InvalidPathException} on Windows. Reserved characters are replaced with '-'
* rather than rejected, so ids keep working unchanged on every platform.
*
* <p>Forward slash and backslash are included as well: an id is a single file-name segment
* here, so either would otherwise be read as a directory separator.
*/
private static final Pattern UNSAFE_SEGMENT_CHARS = Pattern.compile("[<>:\"/\\\\|?*]");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern omits ASCII control characters (U+0000 through U+001F), which Windows also forbids in file-name segments. A caller-supplied id such as a\u0001b is therefore unchanged and still fails when the Windows path is constructed. Include those characters in the transformation and cover them in the test.


/**
* Per-path locks for workspace-relative files to prevent concurrent read-modify-write races.
* Keyed by workspace-relative path (e.g. {@code agents/X/tasks/Y.json},
Expand Down Expand Up @@ -343,18 +356,19 @@ public Path getSessionDir(RuntimeContext rc, String agentId) {
*/
@Deprecated
public Path resolveSessionFile(RuntimeContext rc, String agentId, String sessionId) {
return getSessionDir(rc, agentId).resolve(sessionId + ".json");
return getSessionDir(rc, agentId).resolve(safeSegment(sessionId) + ".json");
}

/** Returns the JSONL session context file path (LLM-facing, compacted). */
public Path resolveSessionContextFile(RuntimeContext rc, String agentId, String sessionId) {
return getSessionDir(rc, agentId)
.resolve(sessionId + WorkspaceConstants.SESSION_CONTEXT_EXT);
.resolve(safeSegment(sessionId) + WorkspaceConstants.SESSION_CONTEXT_EXT);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only changes the local path. SessionTranscriptWriter still builds contextRelativePath from the raw session id, and SessionTree uses that override for mirror reads and uploads. With a Windows-backed LocalFilesystem, the raw colon still reaches Path.of(...); moreover the remote context key and local sanitized key now differ. Derive the mirror key from the same sanitized segment (and test a transcript write with a filesystem configured).

}

/** Returns the JSONL session log file path (full history, append-only). */
public Path resolveSessionLogFile(RuntimeContext rc, String agentId, String sessionId) {
return getSessionDir(rc, agentId).resolve(sessionId + WorkspaceConstants.SESSION_LOG_EXT);
return getSessionDir(rc, agentId)
.resolve(safeSegment(sessionId) + WorkspaceConstants.SESSION_LOG_EXT);
}

/**
Expand Down Expand Up @@ -596,8 +610,27 @@ private Instant diskMtime(Path p) {
}
}

/**
* Replaces characters that are illegal in a Windows/NTFS file-name segment with '-'.
*
* <p>Session ids are caller-supplied and carry no documented character constraint, yet they are
* used verbatim to build file names. Sanitising here keeps an unexpected id from surfacing as a
* raw {@link java.nio.file.InvalidPathException} from deep inside the workspace layer, with no
* hint that the caller-supplied id is the problem.
*/
private static String safeSegment(String segment) {
return segment == null ? null : UNSAFE_SEGMENT_CHARS.matcher(segment).replaceAll("-");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing every unsafe character with - is lossy: distinct allowed IDs such as a:b and a-b resolve to the same context, log, and task-record files. Their transcript history and task maps are then shared (or task records overwritten), while sessions.json still has separate raw-ID entries. Use a collision-free filename encoding or a persisted ID-to-filename mapping.

}

private String taskRecordPath(String agentId, String sessionId) {
return AGENTS_DIR + "/" + agentId + "/" + TASKS_DIR + "/" + sessionId + ".json";
return AGENTS_DIR
+ "/"
+ agentId
+ "/"
+ TASKS_DIR
+ "/"
+ safeSegment(sessionId)
+ ".json";
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,62 @@ void workspaceWritesAllowLogicalAbsoluteAndLiteralDoubleDotNames(@TempDir Path r
"LITERAL",
Files.readString(backend.resolve("some..dir/note.txt"), StandardCharsets.UTF_8));
}

@Test
void sessionFileNamesReplaceWindowsReservedCharacters(@TempDir Path root) throws Exception {
Path template = root.resolve("template");
Path backend = root.resolve("backend");
Files.createDirectories(template);
Files.createDirectories(backend);
RuntimeContext rc = RuntimeContext.empty();

try (WorkspaceManager manager =
new WorkspaceManager(
template,
new LocalFilesystem(
backend, LocalFsMode.ROOTED, PathPolicy.empty(), 10, null))) {
// The shape from #2937: a session id built from namespaced parts.
String sessionId = "agent:abc-123:main:main-9f3c";
String sanitized = "agent-abc-123-main-main-9f3c";

assertFileNameIsSanitized(
manager.resolveSessionFile(rc, "agent-1", sessionId), sanitized);
assertFileNameIsSanitized(
manager.resolveSessionContextFile(rc, "agent-1", sessionId), sanitized);
assertFileNameIsSanitized(
manager.resolveSessionLogFile(rc, "agent-1", sessionId), sanitized);

// Every character NTFS reserves, plus both separators, is replaced.
String allReserved = "a<b>c:d\"e/f\\g|h?i*j";
String allSanitized = "a-b-c-d-e-f-g-h-i-j";
assertFileNameIsSanitized(
manager.resolveSessionFile(rc, "agent-1", allReserved), allSanitized);
}
}

@Test
void sessionFileNamesLeaveOrdinaryIdsUnchanged(@TempDir Path root) throws Exception {
Path template = root.resolve("template");
Path backend = root.resolve("backend");
Files.createDirectories(template);
Files.createDirectories(backend);
RuntimeContext rc = RuntimeContext.empty();

try (WorkspaceManager manager =
new WorkspaceManager(
template,
new LocalFilesystem(
backend, LocalFsMode.ROOTED, PathPolicy.empty(), 10, null))) {
assertFileNameIsSanitized(
manager.resolveSessionFile(rc, "agent-1", "main-9f3c7a2e"), "main-9f3c7a2e");
}
}

private static void assertFileNameIsSanitized(Path file, String sanitizedBaseName) {
String name = file.getFileName().toString();
assertEquals(sanitizedBaseName, name.substring(0, sanitizedBaseName.length()));
assertFalse(
name.substring(sanitizedBaseName.length()).matches(".*[<>:\"/\\\\|?*].*"),
"file name still contains a reserved character: " + name);
}
}
Loading