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
@@ -1,4 +1,4 @@
|
||||
import os, re, json, glob, time, shutil, subprocess
|
||||
import os, re, json, glob, stat, time, shutil, subprocess
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id
|
||||
from lib_py.verify_session import workspace_key
|
||||
@@ -91,6 +91,23 @@ def _session_file_live(data: dict, pid) -> bool:
|
||||
return lstart <= started + 2
|
||||
|
||||
|
||||
def _inside(root: str, path: str) -> bool:
|
||||
"""`path` is reached from `root` through real directories only: no
|
||||
component between the two is a symlink, and its parent resolves inside
|
||||
the resolved root. The root may be a link itself (/tmp -> /private/tmp,
|
||||
a linked ~/.claude), and so may `path`: _remove then takes the link alone."""
|
||||
cur = root
|
||||
for part in os.path.relpath(os.path.dirname(path), root).split(os.sep):
|
||||
cur = os.path.join(cur, part)
|
||||
try:
|
||||
if not stat.S_ISDIR(os.lstat(cur).st_mode):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
real_root = os.path.realpath(root)
|
||||
return os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)
|
||||
|
||||
|
||||
def _remove(path: str) -> None:
|
||||
try:
|
||||
if os.path.islink(path) or not os.path.isdir(path):
|
||||
@@ -267,10 +284,12 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""Every existing path that belongs to this session alone (Issue #31).
|
||||
Shared files that merely mention it (history.jsonl, ~/.claude.json,
|
||||
backups, paste-cache) are not the session's and are never listed."""
|
||||
def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""(root, path) of every existing path that belongs to this session
|
||||
alone (Issue #31), with the root (config, projects or tmp) it was
|
||||
found under. Shared files that merely mention it (history.jsonl,
|
||||
~/.claude.json, backups, paste-cache) are not the session's and are
|
||||
never listed."""
|
||||
# Checked before any path is built: whole directories named by the id
|
||||
# are removed, so "", "." or ".." would reach file-history/ itself,
|
||||
# and "memory" the auto memory. An id that is not a uuid keeps the
|
||||
@@ -278,30 +297,35 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
if not _plausible_id(uuid):
|
||||
return []
|
||||
cfg = _config_dir(ctx)
|
||||
paths = []
|
||||
found = []
|
||||
for pdir in _projects_dirs(ctx):
|
||||
base = os.path.join(pdir, ctx.ws_key, uuid)
|
||||
paths += [p for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",))
|
||||
found += [(pdir, p) for p in ((base + ".jsonl", base) if _is_uuid(uuid) else (base + ".jsonl",))
|
||||
if os.path.lexists(p)]
|
||||
if not _is_uuid(uuid):
|
||||
return paths
|
||||
paths += [p for p in (os.path.join(cfg, "file-history", uuid),
|
||||
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
|
||||
return found
|
||||
found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid),
|
||||
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
|
||||
job = self._owned_job(uuid, ctx)
|
||||
if job:
|
||||
paths.append(job)
|
||||
found.append((cfg, job))
|
||||
for path, pid, data in self._session_files(uuid, ctx):
|
||||
if not _session_file_live(data, pid):
|
||||
# Keys before the session file: the file is what ties them to
|
||||
# this session, so it must outlive them.
|
||||
paths += _key_files(path) + [path]
|
||||
found += [(cfg, p) for p in _key_files(path) + [path]]
|
||||
for root in _tmp_roots():
|
||||
try:
|
||||
keys = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
continue
|
||||
paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p))
|
||||
return paths
|
||||
found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
|
||||
return found
|
||||
|
||||
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
# What lies behind a linked dir below its root is not the session's:
|
||||
# it is kept, and residual_artifacts reports it.
|
||||
return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)]
|
||||
|
||||
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
# Leftovers count without the transcript (a pre-#31 purge left them).
|
||||
@@ -342,7 +366,7 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
files with their (masked) key files. projects is not walked deeper;
|
||||
it can be very large."""
|
||||
if not _is_uuid(uuid):
|
||||
return self._owned_paths(uuid, ctx)
|
||||
return sorted(p for _, p in self._candidates(uuid, ctx))
|
||||
cfg = _config_dir(ctx)
|
||||
pdirs = _projects_dirs(ctx)
|
||||
found = set(scan_names(cfg, uuid, skip=pdirs))
|
||||
@@ -360,18 +384,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
|
||||
except OSError:
|
||||
pass
|
||||
job = self._owned_job(uuid, ctx)
|
||||
if job:
|
||||
found.add(job)
|
||||
# Every candidate, inside its root or behind a link (never purged).
|
||||
found.update(_label(p) for _, p in self._candidates(uuid, ctx))
|
||||
for path, _, _ in self._session_files(uuid, ctx):
|
||||
found.add(path)
|
||||
found.update(_label(k) for k in _key_files(path))
|
||||
for root in _tmp_roots():
|
||||
try:
|
||||
tkeys = os.listdir(root)
|
||||
except OSError:
|
||||
continue
|
||||
found.update(p for p in (os.path.join(root, k, uuid) for k in tkeys) if os.path.lexists(p))
|
||||
return sorted(found)
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
|
||||
Reference in new issue
Block a user