Skip to content

Commit 8aa632d

Browse files
phernandezclaude
andcommitted
fix(core): keep projects under a shared root top-level
Pull the project-name rules out of ProjectService.add_project into project_permalink(name, *, top_level), so every runtime that creates projects applies the same rules. Cloud inserts projects through its own race-safe INSERT ... ON CONFLICT path and could not reuse add_project, so it had silently dropped these rules. The new rule: under a shared root (a configured project root, and Cloud's tenant bucket) a name whose permalink contains '/' is refused. The permalink becomes the project's directory, so 'Research/2026' would live inside 'Research', and anything that treats a project directory as the project's own, such as deleting it with the project, would reach into the nested one. Local projects choose their own paths and keep accepting '/' in names. Refs basicmachines-co/basic-memory-cloud#2102 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015y5WHPT9CNQp1RAttVFuFT
1 parent 3bf2d52 commit 8aa632d

2 files changed

Lines changed: 79 additions & 21 deletions

File tree

src/basic_memory/services/project_service.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ def _is_cloud_only(entry: ProjectEntry) -> bool:
7171
return not (local_copy and os.path.isabs(local_copy))
7272

7373

74+
def project_permalink(name: str, *, top_level: bool) -> str:
75+
"""Return the permalink that addresses a new project, or refuse the name.
76+
77+
Every runtime that creates projects calls this, so a name one of them accepts
78+
is a name all of them accept. ``top_level`` is true where each project owns one
79+
directory directly under a shared root (a configured project root, and Cloud,
80+
where that root is the tenant bucket).
81+
"""
82+
permalink = generate_permalink(name)
83+
# Trigger: a name whose permalink has an empty segment — pure punctuation
84+
# or emoji ('!!!', '💥') reduce to "", and a leading slash ('/foo')
85+
# leaves an empty first segment.
86+
# Why: the permalink is the project's address, and the resolver matches
87+
# it segment by segment against a path whose leading slashes are
88+
# already stripped. An empty segment means no path can ever match it:
89+
# '' advertises at the root as '/', indistinguishable from every other
90+
# such project, and '/foo' advertises '//foo' and cannot be entered.
91+
# Either way the mount view lists something unaddressable (#1421).
92+
# Outcome: refused at the boundary that creates projects, so an
93+
# unaddressable mount cannot exist rather than being handled downstream.
94+
if not all(permalink.split("/")):
95+
raise ValueError(
96+
f"Project name '{name}' has no usable permalink. Names need at least one "
97+
"letter, digit, or CJK character in every path segment, and may not start "
98+
"with '/', so the project has an address."
99+
)
100+
# Trigger: 'Research/2026' under a shared root.
101+
# Why: the permalink becomes the project's directory, so a '/' puts it inside
102+
# another project's directory ('research/2026' under 'research'). Anything
103+
# that treats a project directory as the project's own, such as deleting
104+
# it with the project, would then reach into the nested one.
105+
# Outcome: projects under a shared root stay one directory deep. Local
106+
# projects choose their own paths and may still use '/' in their names.
107+
if top_level and "/" in permalink:
108+
raise ValueError(
109+
f"Project name '{name}' contains '/'. Projects under a shared project root "
110+
"are top-level directories, so their names cannot contain '/'."
111+
)
112+
return permalink
113+
114+
74115
class ProjectService:
75116
"""Service for managing Basic Memory projects."""
76117

@@ -226,34 +267,17 @@ async def add_project(
226267
ValueError: If the project already exists, the name has no permalink,
227268
or the path collides with an existing project
228269
"""
229-
# Trigger: a name whose permalink has an empty segment — pure punctuation
230-
# or emoji ('!!!', '💥') reduce to "", and a leading slash ('/foo')
231-
# leaves an empty first segment.
232-
# Why: the permalink is the project's address, and the resolver matches
233-
# it segment by segment against a path whose leading slashes are
234-
# already stripped. An empty segment means no path can ever match it:
235-
# '' advertises at the root as '/', indistinguishable from every other
236-
# such project, and '/foo' advertises '//foo' and cannot be entered.
237-
# Either way the mount view lists something unaddressable (#1421).
238-
# Outcome: refused at the one boundary that creates projects, so an
239-
# unaddressable mount cannot exist rather than being handled downstream.
240-
if not all(generate_permalink(name).split("/")):
241-
raise ValueError(
242-
f"Project name '{name}' has no usable permalink. Names need at least one "
243-
"letter, digit, or CJK character in every path segment, and may not start "
244-
"with '/', so the project has an address."
245-
)
246-
247270
# If project_root is set, constrain all projects to that directory
248271
project_root = self.config_manager.config.project_root
272+
name_permalink = project_permalink(name, top_level=project_root is not None)
249273
sanitized_name = None
250274
if project_root:
251275
base_path = Path(project_root)
252276

253277
# In cloud mode (when project_root is set), ignore user's path completely
254278
# and use sanitized project name as the directory name
255279
# This ensures flat structure: /app/data/test-bisync instead of /app/data/documents/test bisync
256-
sanitized_name = generate_permalink(name)
280+
sanitized_name = name_permalink
257281

258282
# Construct path using sanitized project name only
259283
resolved_path = (base_path / sanitized_name).resolve().as_posix()
@@ -278,7 +302,6 @@ async def add_project(
278302
# path and the resolver can only pick one, leaving the other
279303
# unreachable and its paths reading the wrong project's content.
280304
# Outcome: refused here, where the second one would be created.
281-
name_permalink = generate_permalink(name)
282305
for existing in existing_projects:
283306
if existing.name != name and generate_permalink(existing.name) == name_permalink:
284307
raise ValueError(

tests/services/test_project_service.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
ActivityMetrics,
1515
SystemStatus,
1616
)
17-
from basic_memory.services.project_service import ProjectService
17+
from basic_memory.services.project_service import ProjectService, project_permalink
1818
from basic_memory.config import ConfigManager, DatabaseBackend
1919
from typing import Any
2020

@@ -241,6 +241,41 @@ async def test_add_project_rejects_names_without_a_permalink(
241241
await project_service.add_project(name, temp_dir)
242242

243243

244+
@pytest.mark.parametrize(
245+
("name", "top_level", "expected"),
246+
[
247+
("Research", True, "research"),
248+
("Research/2026", False, "research/2026"),
249+
],
250+
)
251+
def test_project_permalink_addresses_the_project(name: str, top_level: bool, expected: str):
252+
assert project_permalink(name, top_level=top_level) == expected
253+
254+
255+
def test_project_permalink_keeps_projects_under_a_shared_root_top_level():
256+
"""A '/' under a shared root would put one project's directory inside another's."""
257+
with pytest.raises(ValueError, match="cannot contain '/'"):
258+
project_permalink("Research/2026", top_level=True)
259+
260+
261+
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
262+
@pytest.mark.asyncio
263+
async def test_add_project_under_a_project_root_rejects_a_nested_name(
264+
project_service: ProjectService, monkeypatch
265+
):
266+
with tempfile.TemporaryDirectory() as temp_dir:
267+
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", temp_dir)
268+
from basic_memory import config as config_module
269+
270+
config_module._CONFIG_CACHE = None
271+
config_module._CONFIG_MTIME = None
272+
config_module._CONFIG_SIZE = None
273+
274+
with pytest.raises(ValueError, match="cannot contain '/'"):
275+
await project_service.add_project("Research/2026", "ignored")
276+
assert "Research/2026" not in project_service.projects
277+
278+
244279
@pytest.mark.asyncio
245280
async def test_add_project_rejects_a_colliding_permalink(
246281
project_service: ProjectService, test_project

0 commit comments

Comments
 (0)