Skip to content

Commit eb25fb4

Browse files
jope-bmclaude
andcommitted
fix: skip filesystem mkdir in cloud mode for project creation
ConfigManager.add_project() and ProjectService.move_project() unconditionally called mkdir on the project path. In cloud mode (BASIC_MEMORY_PROJECT_ROOT set), paths resolve under root (/) which is read-only on macOS, crashing local dev. Production Fly.io containers have writable /, so this was never caught there. Skip mkdir when project_root is set since cloud mode uses S3 for file storage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Joe P <joe@basicmemory.com>
1 parent 706fd50 commit eb25fb4

3 files changed

Lines changed: 43 additions & 4 deletions

File tree

src/basic_memory/config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,9 +685,10 @@ def add_project(self, name: str, path: str) -> ProjectConfig:
685685
if project_name: # pragma: no cover
686686
raise ValueError(f"Project '{name}' already exists")
687687

688-
# Ensure the path exists
688+
# Ensure the path exists on disk (skip in cloud mode where storage is S3)
689689
project_path = Path(path)
690-
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
690+
if not self.config.project_root:
691+
project_path.mkdir(parents=True, exist_ok=True) # pragma: no cover
691692

692693
# Load config, modify it, and save it
693694
config = self.load_config()

src/basic_memory/services/project_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,9 @@ async def move_project(self, name: str, new_path: str) -> None:
459459
if name not in self.config_manager.projects:
460460
raise ValueError(f"Project '{name}' not found in configuration")
461461

462-
# Create the new directory if it doesn't exist
463-
Path(resolved_path).mkdir(parents=True, exist_ok=True)
462+
# Create the new directory if it doesn't exist (skip in cloud mode where storage is S3)
463+
if not self.config_manager.config.project_root:
464+
Path(resolved_path).mkdir(parents=True, exist_ok=True)
464465

465466
# Update in configuration
466467
config = self.config_manager.load_config()

tests/test_config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,43 @@ def test_add_project_uses_platform_native_separators(self, monkeypatch):
580580
assert "/" in saved_path
581581
assert saved_path == str(project_path)
582582

583+
def test_add_project_skips_mkdir_when_project_root_set(self, monkeypatch):
584+
"""Test that add_project skips mkdir when project_root is set (cloud mode).
585+
586+
In cloud mode, file storage is S3 — no local directories needed.
587+
Without this, add_project crashes on read-only filesystems (e.g. macOS root /).
588+
"""
589+
import basic_memory.config
590+
591+
with tempfile.TemporaryDirectory() as temp_dir:
592+
temp_path = Path(temp_dir)
593+
594+
# Simulate cloud mode: project_root set, postgres backend
595+
monkeypatch.setenv("BASIC_MEMORY_PROJECT_ROOT", "/cloud-root")
596+
monkeypatch.setenv("BASIC_MEMORY_DATABASE_BACKEND", "postgres")
597+
# Clear module-level cache so env vars are picked up
598+
basic_memory.config._CONFIG_CACHE = None
599+
600+
config_manager = ConfigManager()
601+
config_manager.config_dir = temp_path / "basic-memory"
602+
config_manager.config_file = config_manager.config_dir / "config.json"
603+
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
604+
605+
initial_config = BasicMemoryConfig(projects={})
606+
config_manager.save_config(initial_config)
607+
608+
# Use a path that would fail mkdir on most systems (read-only root)
609+
config_manager.add_project("test-project", "/nonexistent/cloud/path")
610+
611+
# Verify project was added to config without creating the directory
612+
config = config_manager.load_config()
613+
assert "test-project" in config.projects
614+
assert config.projects["test-project"].path == "/nonexistent/cloud/path"
615+
assert not Path("/nonexistent/cloud/path").exists()
616+
617+
# Clean up cache for subsequent tests
618+
basic_memory.config._CONFIG_CACHE = None
619+
583620
def test_model_post_init_uses_platform_native_separators(self, config_home, monkeypatch):
584621
"""Test that model_post_init uses platform-native separators."""
585622
import platform

0 commit comments

Comments
 (0)