From a0e2ba226432c03a9dfdcab1073e4ec5a5116cdf Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:00:33 +0800 Subject: [PATCH 1/3] fix(sessions): surface subagent import I/O failures --- .../_internal/session_import.py | 12 ++++-- tests/test_session_import.py | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/claude_agent_sdk/_internal/session_import.py b/src/claude_agent_sdk/_internal/session_import.py index 52fafb92e..0ad469b6e 100644 --- a/src/claude_agent_sdk/_internal/session_import.py +++ b/src/claude_agent_sdk/_internal/session_import.py @@ -65,6 +65,10 @@ async def import_session_to_store( Raises: ValueError: If ``session_id`` is not a valid UUID. FileNotFoundError: If the session JSONL cannot be found on disk. + OSError: If a transcript, sidecar, or subagent directory exists but + cannot be read. Import is not transactional: entries already + appended remain in the store, and can be safely deduplicated by + ``entry["uuid"]`` when the import is retried. """ if not _validate_uuid(session_id): raise ValueError(f"Invalid session_id: {session_id}") @@ -148,12 +152,14 @@ async def _append_jsonl_file_in_batches( def _collect_jsonl_files(base_dir: Path) -> Iterator[Path]: """Recursively yield all ``*.jsonl`` file paths under ``base_dir``. - Yields nothing if ``base_dir`` does not exist. Sorted per directory so - import order is deterministic across platforms. + Yields nothing if ``base_dir`` does not exist. Other ``OSError``s (for + example, permission denied) propagate so callers cannot mistake a partial + import for a complete one. Sorted per directory so import order is + deterministic across platforms. """ try: dirents = sorted(base_dir.iterdir(), key=lambda p: p.name) - except OSError: + except FileNotFoundError: return for entry in dirents: if entry.is_dir(): diff --git a/tests/test_session_import.py b/tests/test_session_import.py index df1b803e1..1b2fc74ac 100644 --- a/tests/test_session_import.py +++ b/tests/test_session_import.py @@ -2,7 +2,9 @@ from __future__ import annotations +import errno import json +from collections.abc import Iterator from pathlib import Path from unittest.mock import AsyncMock @@ -286,6 +288,46 @@ async def test_no_subagents_dir_is_noop( key: SessionKey = {"project_key": project_key, "session_id": SESSION_ID} assert store.get_entries(key) == [_entry(0)] + @pytest.mark.anyio + @pytest.mark.parametrize("nested", [False, True]) + async def test_unreadable_subagents_dir_raises( + self, + claude_dir: Path, + cwd: Path, + project_key: str, + monkeypatch: pytest.MonkeyPatch, + nested: bool, + ) -> None: + """A failed traversal at any depth must not look like a complete import.""" + _write_jsonl(claude_dir / f"{SESSION_ID}.jsonl", [_entry(0)]) + subagents_dir = claude_dir / SESSION_ID / "subagents" + _write_jsonl(subagents_dir / "agent-abc.jsonl", [_entry(10)]) + blocked_dir = subagents_dir / "workflows" if nested else subagents_dir + if nested: + _write_jsonl(blocked_dir / "run-1" / "agent-def.jsonl", [_entry(20)]) + + original_iterdir = Path.iterdir + + def fail_for_subagents(path: Path) -> Iterator[Path]: + if path == blocked_dir: + raise PermissionError(errno.EACCES, "Permission denied", str(path)) + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", fail_for_subagents) + + store = InMemorySessionStore() + with pytest.raises(PermissionError, match="Permission denied"): + await import_session_to_store(SESSION_ID, store, directory=str(cwd)) + + main_key: SessionKey = { + "project_key": project_key, + "session_id": SESSION_ID, + } + assert store.get_entries(main_key) == [_entry(0)] + assert await store.list_subkeys(main_key) == ( + ["subagents/agent-abc"] if nested else [] + ) + # --------------------------------------------------------------------------- # Validation / errors From 8b8a885f98dee202df2eeff2e3e4e9b341b24c7a Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:21:37 +0800 Subject: [PATCH 2/3] fix(sessions): skip symlinks during subagent import --- .../_internal/session_import.py | 10 ++- tests/test_session_import.py | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/claude_agent_sdk/_internal/session_import.py b/src/claude_agent_sdk/_internal/session_import.py index 0ad469b6e..d8ad47fce 100644 --- a/src/claude_agent_sdk/_internal/session_import.py +++ b/src/claude_agent_sdk/_internal/session_import.py @@ -152,9 +152,11 @@ async def _append_jsonl_file_in_batches( def _collect_jsonl_files(base_dir: Path) -> Iterator[Path]: """Recursively yield all ``*.jsonl`` file paths under ``base_dir``. - Yields nothing if ``base_dir`` does not exist. Other ``OSError``s (for - example, permission denied) propagate so callers cannot mistake a partial - import for a complete one. Sorted per directory so import order is + Yields nothing if ``base_dir`` does not exist. Symlinks below ``base_dir`` + are skipped so they cannot re-enter the tree or import transcripts from + outside it; ``base_dir`` itself may still be a symlink. Other ``OSError``s + (for example, permission denied) propagate so callers cannot mistake a + partial import for a complete one. Sorted per directory so import order is deterministic across platforms. """ try: @@ -162,6 +164,8 @@ def _collect_jsonl_files(base_dir: Path) -> Iterator[Path]: except FileNotFoundError: return for entry in dirents: + if entry.is_symlink(): + continue if entry.is_dir(): yield from _collect_jsonl_files(entry) elif entry.is_file() and entry.name.endswith(".jsonl"): diff --git a/tests/test_session_import.py b/tests/test_session_import.py index 1b2fc74ac..b5724ff5b 100644 --- a/tests/test_session_import.py +++ b/tests/test_session_import.py @@ -53,6 +53,16 @@ def _write_jsonl(path: Path, entries: list[SessionStoreEntry]) -> None: path.write_text("\n".join(json.dumps(e) for e in entries) + "\n", encoding="utf-8") +def _symlink_or_skip( + link: Path, target: Path, *, target_is_directory: bool = True +) -> None: + """Create a symlink, or skip where the platform disallows it.""" + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks are unavailable: {exc}") + + # --------------------------------------------------------------------------- # Main transcript import # --------------------------------------------------------------------------- @@ -328,6 +338,73 @@ def fail_for_subagents(path: Path) -> Iterator[Path]: ["subagents/agent-abc"] if nested else [] ) + @pytest.mark.anyio + @pytest.mark.parametrize("shape", ["cycle", "sibling", "external", "file"]) + async def test_symlinks_below_subagents_are_not_followed( + self, + claude_dir: Path, + cwd: Path, + project_key: str, + tmp_path: Path, + shape: str, + ) -> None: + """Links must not re-enter, duplicate, or escape the transcript tree.""" + _write_jsonl(claude_dir / f"{SESSION_ID}.jsonl", [_entry(0)]) + subagents_dir = claude_dir / SESSION_ID / "subagents" + real_dir = subagents_dir / "real" + _write_jsonl(real_dir / "agent-abc.jsonl", [_entry(10)]) + + if shape == "cycle": + _symlink_or_skip(subagents_dir / "loop", subagents_dir) + elif shape == "sibling": + _symlink_or_skip(subagents_dir / "alias", real_dir) + elif shape == "external": + outside = tmp_path / "outside" + _write_jsonl(outside / "agent-foreign.jsonl", [_entry(20)]) + _symlink_or_skip(subagents_dir / "external", outside) + else: + foreign = tmp_path / "agent-foreign.jsonl" + _write_jsonl(foreign, [_entry(20)]) + _symlink_or_skip( + real_dir / "agent-foreign.jsonl", + foreign, + target_is_directory=False, + ) + + store = InMemorySessionStore() + await import_session_to_store(SESSION_ID, store, directory=str(cwd)) + + main_key: SessionKey = { + "project_key": project_key, + "session_id": SESSION_ID, + } + assert await store.list_subkeys(main_key) == ["subagents/real/agent-abc"] + + @pytest.mark.anyio + async def test_symlinked_subagents_root_is_followed( + self, + claude_dir: Path, + cwd: Path, + project_key: str, + tmp_path: Path, + ) -> None: + """A relocated subagents root remains a supported traversal root.""" + _write_jsonl(claude_dir / f"{SESSION_ID}.jsonl", [_entry(0)]) + relocated = tmp_path / "relocated-subagents" + _write_jsonl(relocated / "agent-abc.jsonl", [_entry(10)]) + session_dir = claude_dir / SESSION_ID + session_dir.mkdir(parents=True) + _symlink_or_skip(session_dir / "subagents", relocated) + + store = InMemorySessionStore() + await import_session_to_store(SESSION_ID, store, directory=str(cwd)) + + main_key: SessionKey = { + "project_key": project_key, + "session_id": SESSION_ID, + } + assert await store.list_subkeys(main_key) == ["subagents/agent-abc"] + # --------------------------------------------------------------------------- # Validation / errors From 2ddf2e8f080b17f887fe3fcde297b0a77136d9be Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:52:53 +0800 Subject: [PATCH 3/3] fix(sessions): skip symlinked metadata sidecars --- .../_internal/session_import.py | 8 ++++- tests/test_session_import.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/session_import.py b/src/claude_agent_sdk/_internal/session_import.py index d8ad47fce..77d2353d8 100644 --- a/src/claude_agent_sdk/_internal/session_import.py +++ b/src/claude_agent_sdk/_internal/session_import.py @@ -17,6 +17,7 @@ from ..types import SessionKey, SessionStore, SessionStoreEntry from .sessions import ( + _agent_metadata_sidecar_path, _read_agent_metadata_sidecar, _resolve_session_file_path, _validate_uuid, @@ -114,7 +115,12 @@ async def import_session_to_store( # recreate it and resumed subagents keep their agentType/worktreePath. # A missing, corrupt, or non-object sidecar is treated as absent (the # transcript is still imported); other read errors propagate. - meta = _read_agent_metadata_sidecar(file_path) + sidecar_path = _agent_metadata_sidecar_path(file_path) + meta = ( + None + if sidecar_path.is_symlink() + else _read_agent_metadata_sidecar(file_path) + ) if meta is not None: # Synthetic discriminator last so a stray "type" key in the # CLI-owned sidecar can never shadow it. diff --git a/tests/test_session_import.py b/tests/test_session_import.py index b5724ff5b..7dcef4224 100644 --- a/tests/test_session_import.py +++ b/tests/test_session_import.py @@ -241,6 +241,35 @@ async def test_meta_json_type_key_cannot_shadow_agent_metadata_marker( "toolUseId": "toolu_1", } + @pytest.mark.anyio + async def test_symlinked_meta_json_sidecar_is_not_followed( + self, claude_dir: Path, cwd: Path, project_key: str, tmp_path: Path + ) -> None: + """A derived sidecar link must not import metadata from outside the tree.""" + _write_jsonl(claude_dir / f"{SESSION_ID}.jsonl", [_entry(0)]) + sub_dir = claude_dir / SESSION_ID / "subagents" + _write_jsonl(sub_dir / "agent-abc.jsonl", [_entry(10)]) + external_sidecar = tmp_path / "agent-abc.meta.json" + external_sidecar.write_text( + json.dumps({"agentType": "external", "worktreePath": "/outside"}), + encoding="utf-8", + ) + _symlink_or_skip( + sub_dir / "agent-abc.meta.json", + external_sidecar, + target_is_directory=False, + ) + + store = InMemorySessionStore() + await import_session_to_store(SESSION_ID, store, directory=str(cwd)) + + sub_key: SessionKey = { + "project_key": project_key, + "session_id": SESSION_ID, + "subpath": "subagents/agent-abc", + } + assert store.get_entries(sub_key) == [_entry(10)] + @pytest.mark.anyio @pytest.mark.parametrize("sidecar", ["not json {", "[1, 2]", "42"]) async def test_unusable_meta_json_sidecar_is_treated_as_absent(