fix(agents): a claude purge never follows a linked dir below its root (Issue #31, Mac review)
A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain directory, and rmtree removed what lay behind the link; projects/<key>, file-history/ and the other config dirs had no such check either. Each candidate now keeps the root it was found under (config, projects or tmp), and is purged only when every component between that root and the leaf is a real directory and its parent resolves inside the resolved root. A root may itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the link alone, and a candidate behind a link is reported as residual instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd09c6a133
commit
6e0361cf43
2 files changed
+114
-24
No files matched your search
@@ -290,6 +290,79 @@ def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
|
||||
assert memory.exists() and all(p.exists() for p in named)
|
||||
|
||||
|
||||
def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv):
|
||||
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
|
||||
not followed: what lies behind it stays and is reported as residual."""
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
cfg = home / ".claude"
|
||||
key = workspace_key(str(ws))
|
||||
out = tmp_path / "outside"
|
||||
victims = [out / "tmp" / conv / "keep.txt", out / "proj" / f"{conv}.jsonl", out / "proj" / conv / "keep.txt",
|
||||
out / "fh" / conv / "keep.txt"]
|
||||
for v in victims:
|
||||
v.parent.mkdir(parents=True, exist_ok=True)
|
||||
v.write_text("keep")
|
||||
troot = tmp_path / "ctmp" / f"claude-{UID}"
|
||||
troot.mkdir(parents=True)
|
||||
links = [troot / "linked-workspace", cfg / "projects" / key, cfg / "file-history"]
|
||||
for link, target in zip(links, (out / "tmp", out / "proj", out / "fh")):
|
||||
link.symlink_to(target)
|
||||
adapter = get_adapter("claude")
|
||||
ctx = _ctx(ws, home)
|
||||
|
||||
assert adapter.purge_artifacts(conv, ctx) == []
|
||||
assert all(v.exists() for v in victims) and all(link.is_symlink() for link in links)
|
||||
assert adapter.residual_artifacts(conv, ctx) == sorted(
|
||||
[str(troot / "linked-workspace" / conv), str(cfg / "projects" / key / f"{conv}.jsonl"),
|
||||
str(cfg / "projects" / key / conv), str(cfg / "file-history" / conv)])
|
||||
|
||||
|
||||
def test_claude_purge_removes_only_the_link_at_the_leaf(home, tmp_path, conv):
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
key = workspace_key(str(ws))
|
||||
out = tmp_path / "outside"
|
||||
keep = [out / "tmp" / "keep.txt", out / "proj" / "keep.txt"]
|
||||
for k in keep:
|
||||
k.parent.mkdir(parents=True)
|
||||
k.write_text("keep")
|
||||
leaves = [tmp_path / "ctmp" / f"claude-{UID}" / key / conv, home / ".claude" / "projects" / key / conv]
|
||||
for leaf, target in zip(leaves, (out / "tmp", out / "proj")):
|
||||
leaf.parent.mkdir(parents=True)
|
||||
leaf.symlink_to(target)
|
||||
adapter = get_adapter("claude")
|
||||
ctx = _ctx(ws, home)
|
||||
|
||||
assert set(adapter.purge_artifacts(conv, ctx)) == {str(p) for p in leaves}
|
||||
assert not any(os.path.lexists(p) for p in leaves)
|
||||
assert all(k.exists() for k in keep)
|
||||
assert adapter.residual_artifacts(conv, ctx) == []
|
||||
|
||||
|
||||
def test_claude_roots_may_be_links(home, tmp_path, monkeypatch, conv):
|
||||
"""A root that is itself a link (/tmp -> /private/tmp on macOS, a linked
|
||||
~/.claude) is the boundary, not a way out of it."""
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
real_cfg, real_tmp = tmp_path / "real-cfg", tmp_path / "real-tmp"
|
||||
real_cfg.mkdir()
|
||||
real_tmp.mkdir()
|
||||
(tmp_path / "cfg-link").symlink_to(real_cfg)
|
||||
(tmp_path / "tmp-link").symlink_to(real_tmp)
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg-link"))
|
||||
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
|
||||
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "tmp-link"))
|
||||
t = _tree(home, ws, conv, tmp_path / "tmp-link", cfg=tmp_path / "cfg-link")
|
||||
adapter = get_adapter("claude")
|
||||
ctx = _ctx(ws, home)
|
||||
|
||||
adapter.purge_artifacts(conv, ctx)
|
||||
|
||||
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
|
||||
assert adapter.residual_artifacts(conv, ctx) == []
|
||||
|
||||
|
||||
def test_a_non_uuid_id_takes_only_its_transcript_file(home, tmp_path):
|
||||
"""The pre-#31 reach, kept for ids that are not uuids (test_a4 purges
|
||||
"uuid-c"): the `<id>.jsonl` file, never a directory of that name."""
|
||||
|
||||
Reference in new issue
Block a user