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
57 changes: 57 additions & 0 deletions python/packages/core/agent_framework/_workflows/_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os
import threading
import time
import unicodedata
import uuid
from collections.abc import Callable, Mapping
from concurrent.futures import Future
Expand Down Expand Up @@ -571,6 +572,61 @@ def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}")
return file_path

@staticmethod
def _assert_no_variant_collision(file_path: Path, checkpoint_id: CheckpointID) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please enforce the same exact checkpoint-ID identity on load() and delete(). On a case/normalization-folding filesystem, after saving Round-A, _validate_file_path("round-a") resolves to the same existing file: load("round-a") returns the other checkpoint and delete("round-a") deletes Round-A. Under the fail-closed policy, a variant ID should not read or remove a checkpoint whose stored ID/spelling differs; add regressions for both operations.

"""Reject an ID that the filesystem cannot store as an independent file.

On case-insensitive filesystems (the default on Windows and macOS),
``Round-A.json`` and ``round-a.json`` name the same file, so a save under
the variant spelling would silently replace the other checkpoint and its
reads would then return the wrong workflow's state (#9444). APFS and HFS+
additionally fold Unicode normalization forms, so ``café-1.json`` spelled
NFC and NFD is also one file there (#9466 review). On filesystems without
those folds the variant path simply does not exist, so the directory scan
below is skipped entirely there.
"""
if not file_path.exists():
return
# Derive the expected spelling from the requested ID, not the resolved
# path: on Windows Path.resolve() canonicalizes an existing file to its
# on-disk casing, so file_path.name would already be the colliding
# name and the check below would accept the overwrite (#9444).
expected_name = f"{checkpoint_id}.json"
try:
siblings = [entry.name for entry in file_path.parent.iterdir()]
except OSError as exc:
# Permissions can allow lookup/creation/replacement while denying
# enumeration, so skipping the scan here would let a variant
# save proceed unchecked. Fail closed instead.
raise WorkflowCheckpointException(
f"Cannot verify checkpoint ID {checkpoint_id!r} against existing checkpoints: "
"the storage directory could not be listed."
) from exc
# The filesystem key is the NFC-normalized, case-folded spelling: both
# folds are lossy on the volumes that perform them, so two IDs sharing
# a key cannot coexist there. str.casefold() alone does not normalize.

def _fs_key(name: str) -> str:
return unicodedata.normalize("NFC", name).casefold()

expected_key = _fs_key(expected_name)
variant = next(
(name for name in siblings if name != expected_name and _fs_key(name) == expected_key),
None,
)
if variant is None or expected_name in siblings:
# No filesystem-folded variant exists, or the exact spelling is
# already stored (a same-ID update on a volume where distinct
# spellings legitimately coexist).
return
raise WorkflowCheckpointException(
f"Checkpoint ID {checkpoint_id!r} collides with the existing checkpoint "
f"{variant.removesuffix('.json')!r}: on this filesystem both names "
"resolve to one file, and saving would replace that checkpoint's data. "
"Choose a checkpoint ID that differs by more than letter case or "
"Unicode normalization form."
)

@classmethod
def _delete_lock(cls, file_path: Path) -> threading.Lock:
"""Return the process-local deletion lock for a checkpoint file."""
Expand All @@ -592,6 +648,7 @@ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value

file_path = self._validate_file_path(checkpoint.checkpoint_id)
self._assert_no_variant_collision(file_path, checkpoint.checkpoint_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This check occurs before _enqueue_write, so two first-time variant saves can both observe no destination and pass before either write lands; the second atomic replace can still overwrite the first. Please serialize by the filesystem-folded collision key and repeat the guard after the predecessor completes/immediately before replacement, with a concurrent-save regression. Cross-process guarantees can remain a separately documented limitation, but same-process saves should not retain the corruption race.

checkpoint_dict = checkpoint.to_dict()
# Fail at save time if encoding or restore validation fails (#8181).
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,109 @@ async def test_load_missing_id_raises(conformance_storage: CheckpointStorage) ->
async def test_save_returns_id(conformance_storage: CheckpointStorage) -> None:
checkpoint = _conformance_checkpoint()
assert await conformance_storage.save(checkpoint) == checkpoint.checkpoint_id


def _filesystem_is_case_insensitive(path: Path) -> bool:
probe = path / "CkAsE.probe"
probe.write_text("x")
try:
return (path / "ckase.probe").exists()
finally:
probe.unlink()


async def test_file_save_rejects_case_variant_checkpoint_id(tmp_path: Path) -> None:
"""A case-only variant ID must not silently replace the other checkpoint (#9444)."""
# Probe inside the test on tmp_path: a module-level probe races under
# pytest-xdist workers sharing the source directory, and tmp_path is the
# volume that actually holds the checkpoints.
if not _filesystem_is_case_insensitive(tmp_path):
pytest.skip("case-variant collision only occurs on case-insensitive filesystems")
storage = FileCheckpointStorage(tmp_path / "checkpoints")
first = WorkflowCheckpoint(
workflow_name="first-workflow",
graph_signature_hash="topology-a",
checkpoint_id="Round-A",
state={"marker": "first"},
)
second = WorkflowCheckpoint(
workflow_name="second-workflow",
graph_signature_hash="topology-b",
checkpoint_id="round-a",
state={"marker": "second"},
)

first_id = await storage.save(first)
with pytest.raises(WorkflowCheckpointException, match="collides"):
await storage.save(second)

# The first checkpoint survives untouched: its data and listing are intact.
restored = await storage.load(first_id)
assert restored.checkpoint_id == "Round-A"
assert restored.workflow_name == "first-workflow"
assert restored.state["marker"] == "first"
listed = await storage.list_checkpoint_ids(workflow_name="first-workflow")
assert listed == ["Round-A"]

# Re-saving the exact same ID remains a normal update, not a collision.
first.state["marker"] = "first-updated"
assert await storage.save(first) == "Round-A"
assert (await storage.load("Round-A")).state["marker"] == "first-updated"


def _filesystem_folds_unicode_normalization(path: Path) -> bool:
"""True when the volume treats NFC and NFD spellings of a name as one file."""
import unicodedata

nfc = unicodedata.normalize("NFC", "café-norm.probe")
nfd = unicodedata.normalize("NFD", "café-norm.probe")
probe = path / nfc
probe.write_text("x")
try:
return (path / nfd).exists()
finally:
probe.unlink()


@pytest.mark.parametrize("mixed_case", [False, True], ids=["nfc-nfd", "case-nfc-nfd"])
async def test_file_save_rejects_normalization_variant_checkpoint_id(tmp_path: Path, mixed_case: bool) -> None:
"""An NFC/NFD spelling variant must not silently replace a checkpoint (#9466).

APFS and HFS+ fold Unicode normalization forms, so two IDs that differ only
in normalization name one file there; str.casefold() alone does not catch
it. On volumes without that fold the two files coexist and there is nothing
to reject.
"""
import unicodedata

if not _filesystem_folds_unicode_normalization(tmp_path):
pytest.skip("normalization-variant collision only occurs on normalization-insensitive filesystems")
first_spelling = unicodedata.normalize("NFC", "Café-1" if mixed_case else "café-1")
second_spelling = unicodedata.normalize("NFD", "café-1")
assert first_spelling != second_spelling # the test would be vacuous otherwise

storage = FileCheckpointStorage(tmp_path / "checkpoints")
first = WorkflowCheckpoint(
workflow_name="first-workflow",
graph_signature_hash="topology-a",
checkpoint_id=first_spelling,
state={"marker": "first"},
)
second = WorkflowCheckpoint(
workflow_name="second-workflow",
graph_signature_hash="topology-b",
checkpoint_id=second_spelling,
state={"marker": "second"},
)

first_id = await storage.save(first)
with pytest.raises(WorkflowCheckpointException, match="collides"):
await storage.save(second)

restored = await storage.load(first_id)
assert restored.checkpoint_id == first_spelling
assert restored.state["marker"] == "first"

# Re-saving the exact same spelling remains a normal update.
first.state["marker"] = "first-updated"
assert await storage.save(first) == first_spelling
Loading