feat: introduce BASIC_MEMORY_PROJECT_ROOT for path constraints (#334) · basicmachines-co/basic-memory@ccc4386 · GitHub
Skip to content

Commit ccc4386

Browse files
authored
feat: introduce BASIC_MEMORY_PROJECT_ROOT for path constraints (#334)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 7616b2b commit ccc4386

5 files changed

Lines changed: 113 additions & 62 deletions

File tree

Dockerfile

Lines changed: 3 additions & 2 deletions

src/basic_memory/config.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ class BasicMemoryConfig(BaseSettings):
103103
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
104104
)
105105

106+
# Project path constraints
107+
project_root: Optional[str] = Field(
108+
default=None,
109+
description="If set, all projects must be created underneath this directory. Paths will be sanitized and constrained to this root. If not set, projects can be created anywhere (default behavior).",
110+
)
111+
106112
# API connection configuration
107113
api_url: Optional[str] = Field(
108114
default=None,
@@ -232,6 +238,10 @@ def data_dir_path(self):
232238
return Path.home() / DATA_DIR_NAME
233239

234240

241+
# Module-level cache for configuration
242+
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
243+
244+
235245
class ConfigManager:
236246
"""Manages Basic Memory configuration."""
237247

@@ -253,12 +263,45 @@ def config(self) -> BasicMemoryConfig:
253263
return self.load_config()
254264

255265
def load_config(self) -> BasicMemoryConfig:
256-
"""Load configuration from file or create default."""
266+
"""Load configuration from file or create default.
267+
268+
Environment variables take precedence over file config values,
269+
following Pydantic Settings best practices.
270+
271+
Uses module-level cache for performance across ConfigManager instances.
272+
"""
273+
global _CONFIG_CACHE
274+
275+
# Return cached config if available
276+
if _CONFIG_CACHE is not None:
277+
return _CONFIG_CACHE
257278

258279
if self.config_file.exists():
259280
try:
260-
data = json.loads(self.config_file.read_text(encoding="utf-8"))
261-
return BasicMemoryConfig(**data)
281+
file_data = json.loads(self.config_file.read_text(encoding="utf-8"))
282+
283+
# First, create config from environment variables (Pydantic will read them)
284+
# Then overlay with file data for fields that aren't set via env vars
285+
# This ensures env vars take precedence
286+
287+
# Get env-based config fields that are actually set
288+
env_config = BasicMemoryConfig()
289+
env_dict = env_config.model_dump()
290+
291+
# Merge: file data as base, but only use it for fields not set by env
292+
# We detect env-set fields by comparing to default values
293+
merged_data = file_data.copy()
294+
295+
# For fields that have env var overrides, use those instead of file values
296+
# The env_prefix is "BASIC_MEMORY_" so we check those
297+
for field_name in BasicMemoryConfig.model_fields.keys():
298+
env_var_name = f"BASIC_MEMORY_{field_name.upper()}"
299+
if env_var_name in os.environ:
300+
# Environment variable is set, use it
301+
merged_data[field_name] = env_dict[field_name]
302+
303+
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
304+
return _CONFIG_CACHE
262305
except Exception as e: # pragma: no cover
263306
logger.exception(f"Failed to load config: {e}")
264307
raise e
@@ -268,8 +311,11 @@ def load_config(self) -> BasicMemoryConfig:
268311
return config
269312

270313
def save_config(self, config: BasicMemoryConfig) -> None:
271-
"""Save configuration to file."""
314+
"""Save configuration to file and invalidate cache."""
315+
global _CONFIG_CACHE
272316
save_basic_memory_config(self.config_file, config)
317+
# Invalidate cache so next load_config() reads fresh data
318+
_CONFIG_CACHE = None
273319

274320
@property
275321
def projects(self) -> Dict[str, str]:

src/basic_memory/services/project_service.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,12 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
9999
Raises:
100100
ValueError: If the project already exists
101101
"""
102-
# in cloud mode, don't allow arbitrary paths.
103-
if self.config_manager.config.cloud_mode_enabled:
104-
basic_memory_home = os.getenv("BASIC_MEMORY_HOME")
105-
assert basic_memory_home is not None
106-
base_path = Path(basic_memory_home)
102+
# If project_root is set, constrain all projects to that directory
103+
project_root = self.config_manager.config.project_root
104+
if project_root:
105+
base_path = Path(project_root)
107106

108-
# Sanitize the input path for cloud mode
107+
# Sanitize the input path
109108
# Strip leading slashes, home directory references, and parent directory references
110109
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
111110

@@ -116,13 +115,14 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
116115
path_parts.append(part)
117116
clean_path = "/".join(path_parts) if path_parts else ""
118117

119-
# Construct path relative to BASIC_MEMORY_HOME
118+
# Construct path relative to project_root
120119
resolved_path = (base_path / clean_path).resolve().as_posix()
121120

122-
# Verify the resolved path is actually under BASIC_MEMORY_HOME
121+
# Verify the resolved path is actually under project_root
123122
if not resolved_path.startswith(base_path.resolve().as_posix()):
124123
raise ValueError(
125-
f"Cloud mode requires projects under {basic_memory_home}. Invalid path: {path}"
124+
f"BASIC_MEMORY_PROJECT_ROOT is set to {project_root}. "
125+
f"All projects must be created under this directory. Invalid path: {path}"
126126
)
127127
else:
128128
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
7878
def config_manager(
7979
app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch
8080
) -> ConfigManager:
81+
# Invalidate config cache to ensure clean state for each test
82+
from basic_memory import config as config_module
83+
84+
config_module._CONFIG_CACHE = None
85+
8186
# Create a new ConfigManager that uses the test home directory
8287
config_manager = ConfigManager()
8388
# Update its paths to use the test directory

tests/services/test_project_service.py

Lines changed: 46 additions & 47 deletions

0 commit comments

Comments
 (0)