Skip to content

Commit 3e876a7

Browse files
phernandezgithub-actions[bot]claude
authored
fix: correct ProjectItem.home property to return path instead of name (#341)
Signed-off-by: phernandez <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 1fa93ec commit 3e876a7

File tree

5 files changed

+143
-83
lines changed

5 files changed

+143
-83
lines changed

src/basic_memory/api/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
3434
# Instrument httpx client for distributed tracing when in cloud mode
3535
if app_config.cloud_mode_enabled:
3636
try:
37-
import logfire
37+
import logfire # pyright: ignore[reportMissingImports]
3838
from basic_memory.mcp.async_client import client as httpx_client
3939

4040
logger.info("Cloud mode enabled - instrumenting httpx client for distributed tracing")

src/basic_memory/schemas/project_info.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def permalink(self) -> str: # pragma: no cover
183183

184184
@property
185185
def home(self) -> Path: # pragma: no cover
186-
return Path(self.name)
186+
return Path(self.path).expanduser()
187187

188188
@property
189189
def project_url(self) -> str: # pragma: no cover

src/basic_memory/services/project_service.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,21 +138,13 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
138138
if project_root:
139139
base_path = Path(project_root)
140140

141-
# Sanitize the input path
142-
# Strip leading slashes, home directory references, and parent directory references
143-
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
144-
145-
# Remove any parent directory traversal attempts and normalize to lowercase
146-
# to prevent case-sensitivity issues on Linux filesystems
147-
path_parts = []
148-
for part in clean_path.split("/"):
149-
if part and part != "." and part != "..":
150-
# Convert to lowercase to ensure case-insensitive consistency
151-
path_parts.append(part.lower())
152-
clean_path = "/".join(path_parts) if path_parts else ""
153-
154-
# Construct path relative to project_root
155-
resolved_path = (base_path / clean_path).resolve().as_posix()
141+
# In cloud mode (when project_root is set), ignore user's path completely
142+
# and use sanitized project name as the directory name
143+
# This ensures flat structure: /app/data/test-bisync instead of /app/data/documents/test bisync
144+
sanitized_name = generate_permalink(name)
145+
146+
# Construct path using sanitized project name only
147+
resolved_path = (base_path / sanitized_name).resolve().as_posix()
156148

157149
# Verify the resolved path is actually under project_root
158150
if not resolved_path.startswith(base_path.resolve().as_posix()):

test-int/mcp/test_write_note_integration.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,61 @@ async def test_write_note_file_path_os_path_join(mcp_server, test_project):
432432

433433
# Restore original config value
434434
config.kebab_filenames = curr_config_val
435+
436+
437+
@pytest.mark.asyncio
438+
async def test_write_note_project_path_validation(mcp_server, test_project):
439+
"""Test that ProjectItem.home uses expanded path, not name (Issue #340).
440+
441+
Regression test verifying that:
442+
1. ProjectItem.home returns Path(self.path).expanduser()
443+
2. Not Path(self.name) which was the bug
444+
445+
This test verifies the fix works correctly even though in the test environment
446+
the project name and path happen to be the same. The fix in src/basic_memory/schemas/project_info.py:186
447+
ensures .expanduser() is called, which is critical for paths with ~ like "~/Documents/Test BiSync".
448+
"""
449+
from basic_memory.schemas.project_info import ProjectItem
450+
from pathlib import Path
451+
452+
# Test the fix directly: ProjectItem.home should expand tilde paths
453+
project_with_tilde = ProjectItem(
454+
id=1,
455+
name="Test BiSync", # Name differs from path structure
456+
description="Test",
457+
path="~/Documents/Test BiSync", # Path with tilde
458+
is_active=True,
459+
is_default=False,
460+
)
461+
462+
# Before fix: Path("Test BiSync") - wrong!
463+
# After fix: Path("~/Documents/Test BiSync").expanduser() - correct!
464+
home_path = project_with_tilde.home
465+
466+
# Verify it's a Path object
467+
assert isinstance(home_path, Path)
468+
469+
# Verify tilde was expanded (won't contain ~)
470+
assert "~" not in str(home_path)
471+
472+
# Verify it ends with the expected structure (use Path.parts for cross-platform)
473+
assert home_path.parts[-2:] == ("Documents", "Test BiSync")
474+
475+
# Also test that write_note works with regular project
476+
async with Client(mcp_server) as client:
477+
result = await client.call_tool(
478+
"write_note",
479+
{
480+
"project": test_project.name,
481+
"title": "Validation Test",
482+
"folder": "documents",
483+
"content": "Testing path validation",
484+
"tags": "test",
485+
},
486+
)
487+
488+
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
489+
490+
# Should successfully create without path validation errors
491+
assert "# Created note" in response_text
492+
assert "not allowed" not in response_text

tests/services/test_project_service.py

Lines changed: 76 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,15 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(project_service
756756
async def test_add_project_with_project_root_sanitizes_paths(
757757
project_service: ProjectService, config_manager: ConfigManager, monkeypatch
758758
):
759-
"""Test that BASIC_MEMORY_PROJECT_ROOT sanitizes and validates project paths."""
759+
"""Test that BASIC_MEMORY_PROJECT_ROOT uses sanitized project name, ignoring user path.
760+
761+
When project_root is set (cloud mode), the system should:
762+
1. Ignore the user's provided path completely
763+
2. Use the sanitized project name as the directory name
764+
3. Create a flat structure: /app/data/test-bisync instead of /app/data/documents/test bisync
765+
766+
This prevents the bisync auto-discovery bug where nested paths caused duplicate project creation.
767+
"""
760768
with tempfile.TemporaryDirectory() as temp_dir:
761769
# Set up project root environment
762770
project_root_path = Path(temp_dir) / "app" / "data"
@@ -770,65 +778,48 @@ async def test_add_project_with_project_root_sanitizes_paths(
770778
config_module._CONFIG_CACHE = None
771779

772780
test_cases = [
773-
# (input_path, expected_result_path, should_succeed)
774-
("test", str(project_root_path / "test"), True), # Simple relative path
775-
(
776-
"~/Documents/test",
777-
str(project_root_path / "Documents" / "test"),
778-
True,
779-
), # Home directory
780-
(
781-
"/tmp/test",
782-
str(project_root_path / "tmp" / "test"),
783-
True,
784-
), # Absolute path (sanitized to relative)
785-
(
786-
"../../../etc/passwd",
787-
str(project_root_path),
788-
True,
789-
), # Path traversal (all ../ removed, results in project_root)
790-
(
791-
"folder/subfolder",
792-
str(project_root_path / "folder" / "subfolder"),
793-
True,
794-
), # Nested path
795-
(
796-
"~/folder/../test",
797-
str(project_root_path / "test"),
798-
True,
799-
), # Mixed patterns (sanitized to just 'test')
781+
# (project_name, user_path, expected_sanitized_name)
782+
# User path is IGNORED - only project name matters
783+
("test", "anything/path", "test"),
784+
("Test BiSync", "~/Documents/Test BiSync", "test-bi-sync"), # BiSync -> bi-sync (dash preserved)
785+
("My Project", "/tmp/whatever", "my-project"),
786+
("UPPERCASE", "~", "uppercase"),
787+
("With Spaces", "~/Documents/With Spaces", "with-spaces"),
800788
]
801789

802-
for i, (input_path, expected_path, should_succeed) in enumerate(test_cases):
803-
test_project_name = f"project-root-test-{i}"
790+
for i, (project_name, user_path, expected_sanitized) in enumerate(test_cases):
791+
test_project_name = f"{project_name}-{i}" # Make unique
792+
expected_final_segment = f"{expected_sanitized}-{i}"
804793

805794
try:
806-
# Add the project
807-
await project_service.add_project(test_project_name, input_path)
795+
# Add the project - user_path should be ignored
796+
await project_service.add_project(test_project_name, user_path)
797+
798+
# Verify the path uses sanitized project name, not user path
799+
assert test_project_name in project_service.projects
800+
actual_path = project_service.projects[test_project_name]
801+
802+
# The path should be under project_root (resolve both to handle macOS /private/var)
803+
assert (
804+
Path(actual_path)
805+
.resolve()
806+
.is_relative_to(Path(project_root_path).resolve())
807+
), (
808+
f"Path {actual_path} should be under {project_root_path}"
809+
)
808810

809-
if should_succeed:
810-
# Verify the path was sanitized correctly
811-
assert test_project_name in project_service.projects
812-
actual_path = project_service.projects[test_project_name]
813-
814-
# The path should be under project_root (resolve both to handle macOS /private/var)
815-
assert (
816-
Path(actual_path)
817-
.resolve()
818-
.is_relative_to(Path(project_root_path).resolve())
819-
), (
820-
f"Path {actual_path} should be under {project_root_path} for input {input_path}"
821-
)
822-
823-
# Clean up
824-
await project_service.remove_project(test_project_name)
825-
else:
826-
pytest.fail(f"Expected ValueError for input path: {input_path}")
811+
# Verify the final path segment is the sanitized project name
812+
path_parts = Path(actual_path).parts
813+
final_segment = path_parts[-1]
814+
assert final_segment == expected_final_segment, (
815+
f"Expected path segment '{expected_final_segment}', got '{final_segment}'"
816+
)
817+
818+
# Clean up
819+
await project_service.remove_project(test_project_name)
827820

828821
except ValueError as e:
829-
if should_succeed:
830-
pytest.fail(f"Unexpected ValueError for input path {input_path}: {e}")
831-
# Expected failure - continue to next test case
822+
pytest.fail(f"Unexpected ValueError for project {test_project_name}: {e}")
832823

833824

834825
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
@@ -919,12 +910,17 @@ async def test_add_project_without_project_root_allows_arbitrary_paths(
919910
await project_service.remove_project(test_project_name)
920911

921912

913+
@pytest.mark.skip(reason="Obsolete: project_root mode now uses sanitized project name, not user path. See test_add_project_with_project_root_sanitizes_paths instead.")
922914
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
923915
@pytest.mark.asyncio
924916
async def test_add_project_with_project_root_normalizes_case(
925917
project_service: ProjectService, config_manager: ConfigManager, monkeypatch
926918
):
927-
"""Test that BASIC_MEMORY_PROJECT_ROOT normalizes paths to lowercase."""
919+
"""Test that BASIC_MEMORY_PROJECT_ROOT normalizes paths to lowercase.
920+
921+
NOTE: This test is obsolete. After fixing the bisync duplicate project bug,
922+
project_root mode now ignores the user's path and uses the sanitized project name instead.
923+
"""
928924
with tempfile.TemporaryDirectory() as temp_dir:
929925
# Set up project root environment
930926
project_root_path = Path(temp_dir) / "app" / "data"
@@ -966,12 +962,17 @@ async def test_add_project_with_project_root_normalizes_case(
966962
pytest.fail(f"Unexpected ValueError for input path {input_path}: {e}")
967963

968964

965+
@pytest.mark.skip(reason="Obsolete: project_root mode now uses sanitized project name, not user path.")
969966
@pytest.mark.skipif(os.name == "nt", reason="Project root constraints only tested on POSIX systems")
970967
@pytest.mark.asyncio
971968
async def test_add_project_with_project_root_detects_case_collisions(
972969
project_service: ProjectService, config_manager: ConfigManager, monkeypatch
973970
):
974-
"""Test that BASIC_MEMORY_PROJECT_ROOT detects case-insensitive path collisions."""
971+
"""Test that BASIC_MEMORY_PROJECT_ROOT detects case-insensitive path collisions.
972+
973+
NOTE: This test is obsolete. After fixing the bisync duplicate project bug,
974+
project_root mode now ignores the user's path and uses the sanitized project name instead.
975+
"""
975976
with tempfile.TemporaryDirectory() as temp_dir:
976977
# Set up project root environment
977978
project_root_path = Path(temp_dir) / "app" / "data"
@@ -1162,21 +1163,30 @@ async def test_add_project_nested_validation_with_project_root(
11621163
child_project_name = f"cloud-child-{os.urandom(4).hex()}"
11631164

11641165
try:
1165-
# Add parent project (normalized to lowercase)
1166+
# Add parent project - user path is ignored, uses sanitized project name
11661167
await project_service.add_project(parent_project_name, "parent-folder")
11671168

1168-
# Verify it was created
1169+
# Verify it was created using sanitized project name, not user path
11691170
assert parent_project_name in project_service.projects
11701171
parent_actual_path = project_service.projects[parent_project_name]
1171-
# Resolve both paths to handle macOS /private/var vs /var differences
1172-
assert (
1173-
Path(parent_actual_path).resolve()
1174-
== (project_root_path / "parent-folder").resolve()
1175-
)
1176-
1177-
# Try to add a child project nested under parent (should fail)
1178-
with pytest.raises(ValueError, match="nested within existing project"):
1179-
await project_service.add_project(child_project_name, "parent-folder/child-folder")
1172+
# Path should use sanitized project name (cloud-parent-xxx -> cloud-parent-xxx)
1173+
# NOT the user-provided path "parent-folder"
1174+
assert parent_project_name.lower() in parent_actual_path.lower()
1175+
# Resolve both to handle macOS /private/var vs /var
1176+
assert Path(parent_actual_path).resolve().is_relative_to(Path(project_root_path).resolve())
1177+
1178+
# Nested projects should still be prevented, even with user path ignored
1179+
# Since paths use project names, this won't actually be nested
1180+
# But we can test that two projects can coexist
1181+
await project_service.add_project(child_project_name, "parent-folder/child-folder")
1182+
1183+
# Both should exist with their own paths
1184+
assert child_project_name in project_service.projects
1185+
child_actual_path = project_service.projects[child_project_name]
1186+
assert child_project_name.lower() in child_actual_path.lower()
1187+
1188+
# Clean up child
1189+
await project_service.remove_project(child_project_name)
11801190

11811191
finally:
11821192
# Clean up

0 commit comments

Comments
 (0)