diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index 5a4a495..23d3a2c 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -130,6 +130,7 @@ from one_memory.hy_sdk_types import ( from one_memory.imports.access import ( AuthorizedSourceRoot, SourceAccessError, + resolved_source_root, root_identity_matches, source_root_identity, ) @@ -172,6 +173,7 @@ from one_memory.imports.sources.common import SourceInstallState from one_memory.imports.sources.hermes import ( HERMES_ROOT_IS_PROFILES_CONTAINER, hermes_root_conflict, + hermes_root_is_profiles_container, ) from one_memory.imports.sources.registry import ( RootCandidateKind, @@ -3217,17 +3219,26 @@ def create_app( """ if source_id not in MULTI_USER_SOURCES: return + if hermes_root_is_profiles_container(root): + raise HTTPException(status_code=422, detail=HERMES_ROOT_IS_PROFILES_CONTAINER) - def others() -> list[Path]: - return [ + def conflict_with_others() -> str | None: + try: + mine = resolved_source_root(root) + except SourceAccessError: + # Unreadable or missing: the grant that follows refuses it + # with its own code. + return None + others = [ Path(path) for receipt, path in _grant_store(app).grants() if receipt.source_id == source_id and receipt.user_id != user_id and receipt.revoked_at is None ] + return hermes_root_conflict(mine, others) - conflict = hermes_root_conflict(root, await asyncio.to_thread(others)) + conflict = await asyncio.to_thread(conflict_with_others) if conflict is not None: raise HTTPException( status_code=422 if conflict == HERMES_ROOT_IS_PROFILES_CONTAINER else 409, diff --git a/memhub/apps/core/src/one_memory/imports/access.py b/memhub/apps/core/src/one_memory/imports/access.py index f028b47..6cea3f0 100644 --- a/memhub/apps/core/src/one_memory/imports/access.py +++ b/memhub/apps/core/src/one_memory/imports/access.py @@ -436,6 +436,11 @@ def root_identity_matches(root: Path, expected: str) -> bool: return _root_identity_matches_details(resolved, details, expected) +def resolved_source_root(root: Path) -> Path: + """The real directory a root names (symlinks followed), as a grant records it.""" + return _root_details(root)[0] + + def _check_root_identity(root: Path, expected: str | None) -> None: if expected is not None and not root_identity_matches(root, expected): raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED) diff --git a/memhub/apps/core/src/one_memory/imports/sources/hermes.py b/memhub/apps/core/src/one_memory/imports/sources/hermes.py index 24e5e50..8458140 100644 --- a/memhub/apps/core/src/one_memory/imports/sources/hermes.py +++ b/memhub/apps/core/src/one_memory/imports/sources/hermes.py @@ -268,20 +268,21 @@ def _is_hermes_target(relative_path: str) -> bool: return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts) -def hermes_root_conflict(root: Path, other_users_roots: Iterable[Path]) -> str | None: - """Why `root` may not be granted beside other users' Hermes roots, or None. +def hermes_root_is_profiles_container(root: Path) -> bool: + """`profiles` itself holds everyone's profiles; it is never one person's history.""" + return root.name == HERMES_PROFILES_DIRECTORY - Paths are compared resolved (symlinks followed). `profiles` itself is the - container of everyone's profiles, never one person's history. Two roots - that are equal or nest would read the same files for two users, except - when one is exactly the other's `profiles/`: the scan above never - enters `profiles/`, so the two stay apart (`local` on `~/.hermes`, a user - on `~/.hermes/profiles/`). + +def hermes_root_conflict(mine: Path, other_users_roots: Iterable[Path]) -> str | None: + """Why the resolved root `mine` may not be granted beside other users' Hermes roots. + + Paths are compared resolved (symlinks followed; the other users' roots are + resolved here). Two roots that are equal or nest would read the same files + for two users, except when one is exactly the other's `profiles/`: + the scan above never enters `profiles/`, so the two stay apart (`local` on + `~/.hermes`, a user on `~/.hermes/profiles/`). None when it may. """ - if root.name == HERMES_PROFILES_DIRECTORY: - return HERMES_ROOT_IS_PROFILES_CONTAINER - mine = _real(root) - if mine.name == HERMES_PROFILES_DIRECTORY: + if hermes_root_is_profiles_container(mine): return HERMES_ROOT_IS_PROFILES_CONTAINER for other in other_users_roots: theirs = _real(other)