Repository navigation
feat(core): add checkpoint namespaces - #139
kaihao-zhao wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
9 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @dev-unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| * @return a configuration using the requested namespace | ||
| */ | ||
| public RunnableConfig withCheckpointNamespace(String checkpointNamespace) { | ||
| if (Objects.equals(this.threadId, checkpointNamespace)) { |
There was a problem hiding this comment.
The short-circuit check reads this.threadId instead of this.checkpointNamespace. When the thread ID happens to equal the requested namespace (e.g. config.withCheckpointNamespace("conversation-42") on a config whose threadId is "conversation-42"), the method returns this unchanged, so the namespace is silently not applied and checkpoints keep landing in the old namespace. The existing test in RunnableConfigTest misses this because its threadId ("conversation-42") differs from the namespace ("tenant-globex").
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { |
| /** | ||
| * Returns the namespace used to partition checkpoints. | ||
| * | ||
| * @return the configured namespace, or {@code $default} when no namespace is set |
There was a problem hiding this comment.
The accessor's @return says the method returns $default when no namespace is set, but the implementation returns ofNullable(checkpointNamespace) — i.e. an empty Optional. The $default substitution only happens later in BaseCheckpointSaver.checkpointNamespace(RunnableConfig). A caller trusting this javadoc would expect a non-empty Optional and could skip its own default handling.
| * @return the configured namespace, or {@code $default} when no namespace is set | |
| * @return the configured namespace, or an empty {@code Optional} if no namespace is set |
| * @return namespace-qualified thread key | ||
| */ | ||
| default String checkpointKey(RunnableConfig config) { | ||
| return "%s:%s".formatted(checkpointNamespace(config), threadId(config)); |
There was a problem hiding this comment.
checkpointKey joins namespace and threadId with ":", but threadIds are never validated and may themselves contain ":". Two distinct (namespace, threadId) pairs then produce the same key: namespace "a" + threadId "b:c" and namespace "a:b" + threadId "c" both yield "a:b:c", so MemorySaver returns one tenant's checkpoints to the other. Use a separator that cannot appear in either component, or escape/validate the inputs before joining (the documented namespace charset — letters, digits, hyphens, underscores — would work if it were actually enforced, see the core-library.md comment).
| final var checkpointKey = checkpointKey(config); | ||
| return new Tag( threadId(config), _checkpointsByThread.remove( checkpointKey ) ); |
There was a problem hiding this comment.
MemorySaver now stores checkpoints under the namespace-qualified checkpointKey, but VersionedMemorySaver (which delegates to a MemorySaver) keys _checkpointsHistoryByThread by bare threadId in its release() (VersionedMemorySaver.java:175) and in versionsByThreadId/lastVersionByThreadId/getCheckpointsByVersion. Two tenants sharing a threadId now get isolated live checkpoints but their released Tags are interleaved into the same version history — versionsByThreadId and getCheckpointsByVersion hand one tenant the other tenant's checkpoint history. Since this PR's stated goal is namespace isolation for the built-in memory savers, VersionedMemorySaver should key its history by checkpointKey(config) too (and HasVersions callers should pass the namespace along).
| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
getNamespaceFolder resolves the raw namespace string under targetFolder with no validation or normalization. A namespace like "../evil" (or any value containing / or \) makes getPath/getFile point outside the configured checkpoint root, so put writes and deleteFile deletes arbitrary files reachable from the process. Even without hostile intent, namespaces containing characters that are illegal in path segments (:, ?, * on Windows) make every save/load fail. The docs promise trimming and a [A-Za-z0-9_-] charset (see the core-library.md comment) — that validation should actually be enforced here (e.g. in RunnableConfig.Builder.checkpointNamespace or in getNamespaceFolder), rejecting .., path separators, and other illegal characters before resolving.
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); |
There was a problem hiding this comment.
getNamespaceFolder branches on the raw Optional rather than the defaulted value, so an explicitly configured checkpointNamespace("$default") resolves to targetFolder/$default, while omitting the namespace resolves to targetFolder itself. MemorySaver treats both identically (both become the key "$default:<threadId>" via checkpointKey). The same logical namespace therefore isolates differently depending on which saver is used. Either document that "$default" is a reserved value that must not be set explicitly, or normalize an explicit "$default" to the absent-namespace behavior consistently in both savers.
| @Override | ||
| protected Tag releaseCheckpoints(RunnableConfig config, LinkedList<Checkpoint> checkpoints) throws Exception { | ||
| final var currentPath = getPath(config); | ||
| final var namesapceFolder = getNamespaceFolder(config); |
There was a problem hiding this comment.
The local variable namesapceFolder is misspelled (namespace). Rename it here and at its four usages (lines 157, 166, 172) so the code matches the terminology used everywhere else in this change.
| | Attribute | Type | Description | | ||
| | --------- | ---- | ----------- | | ||
| | **threadId** | `String` | A unique identifier for the execution thread/session. Essential for checkpoint-based persistence, as it groups related executions together. Allows resuming interrupted graphs or maintaining conversation history. | | ||
| | **checkpointNamespace** | `String` | Optional partition for checkpoint data. Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores. | |
There was a problem hiding this comment.
The table states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but RunnableConfig.Builder.checkpointNamespace() (RunnableConfig.java:314-315) assigns the value as-is — no trimming, no charset check, no rejection. Today the contradiction is not cosmetic: FileSystemSaver uses the value directly as a folder name, so the promised guarantee is exactly what would prevent path traversal and invalid-path failures (see the FileSystemSaver comment). Either implement the validation in the builder (and keep the doc) or reword the doc to state that values are used verbatim.
| default String checkpointNamespace(RunnableConfig config) { | ||
| return config.checkpointNamespace().orElse(CHECKPOINT_NAMESPACE_DEFAULT); | ||
| } |
There was a problem hiding this comment.
This PR introduces checkpointNamespace as a tenant-partition mechanism, but only MemorySaver and FileSystemSaver were updated to use it. All other BaseCheckpointSaver implementations — PostgresSaver, RedisSaver, DynamoDBSaver, OracleSaver, AbstractMysqlServer/MysqlSaver, CockroachDBSaver, HazelcastSaver, and core's own VersionedMemorySaver (whose _checkpointsHistoryByThread is keyed by raw threadId) — still key storage solely by threadId. A deployment that sets checkpointNamespace("tenant-a") on a config and uses, say, PostgresSaver gets zero isolation with no error or warning: two tenants sharing a threadId read and overwrite each other's persisted state, the exact cross-tenant leak the feature advertises. At minimum, the other savers should incorporate checkpointKey(config) (or an equivalent namespace-qualified column/field), or the API should fail loudly when a namespace is set on a saver that ignores it.
Risk AssessmentThe PR does not change database schema, so neither schema policy matches. |
There was a problem hiding this comment.
2 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked-local-kaihao to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| public RunnableConfig withCheckpointNamespace(String checkpointNamespace) { | ||
| if (Objects.equals(this.threadId, checkpointNamespace)) { | ||
| return this; | ||
| } |
There was a problem hiding this comment.
The short-circuit guard compares the wrong field: this.threadId is checked against the new checkpointNamespace instead of this.checkpointNamespace. This causes two bugs:
- If the requested namespace happens to equal the current
threadId, the method returnsthiswithout updating the namespace. - If the requested namespace equals the current namespace (but differs from
threadId), it needlessly allocates a new config.
The existing withCheckPointId uses the correct pattern: Objects.equals(this.checkPointId, checkPointId). Apply the same here.
if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {
return this;
}| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
getNamespaceFolder passes the raw checkpointNamespace directly to targetFolder.resolve(...). Path.resolve has two dangerous behaviors with untrusted input:
- An absolute namespace (e.g.
/etc/passwd) is returned verbatim, ignoringtargetFolderentirely. - A relative namespace containing
..(e.g.../../sensitive) resolves outsidetargetFolder.
This affects getPath, serialize (which then calls Files.createDirectories on the escaped parent), releaseCheckpoints, and deleteFile, so an attacker-controlled namespace can read/write/delete arbitrary files reachable by the process.
The documentation already states the contract — "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores" — but it is never enforced. Validate the namespace against that pattern (and reject absolute paths) before resolving, for example in BaseCheckpointSaver.checkpointNamespace(RunnableConfig) or in getNamespaceFolder, throwing IllegalArgumentException on violation. At minimum, normalize and confirm the resolved path starts with targetFolder.
Based on AGENT.md
Risk AssessmentThe new |
Adds checkpoint namespace support to RunnableConfig and applies namespace isolation to the built-in memory and filesystem checkpoint savers. Includes API documentation, backward-compatible default behavior, and focused tests for tenant isolation. Test command: ./mvnw -pl langgraph4j-core test.