fix(config): resolve database_url relative to CLEVERAGENTS_HOME, not CWD #1168

Merged
HAL9000 merged 5 commits from bugfix/m4-sqlite-url-cwd into master 2026-04-22 01:54:41 +00:00
10 changed files with 201 additions and 35 deletions
+13 -5
View File
@@ -15,9 +15,13 @@ def step_create_settings(context):
# Clear any existing singleton
if hasattr(Settings, "_instance"):
Settings._instance = None
# Remove database URL env vars so we test the actual pydantic defaults,
# not the per-scenario temp paths injected by environment.py.
for key in ("CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL"):
# Remove database URL and home env vars so we test the actual pydantic
# defaults, not the per-scenario temp paths injected by environment.py.
for key in (
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
"CLEVERAGENTS_HOME",
):
os.environ.pop(key, None)
context.settings = Settings()
@@ -50,11 +54,15 @@ def step_verify_production_status(context):
@when("I get the database URL from Settings")
def step_get_database_url(context):
"""Get database URL."""
# Clear singleton and database URL env vars so we test the actual
# Clear singleton and database URL/home env vars so we test the actual
# pydantic defaults, not per-scenario temp paths from environment.py.
if hasattr(Settings, "_instance"):
Settings._instance = None
for key in ("CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL"):
for key in (
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
"CLEVERAGENTS_HOME",
):
os.environ.pop(key, None)
settings = Settings()
context.db_url = settings.get_database_url()
+8
View File
@@ -32,6 +32,14 @@ def step_clear_env_vars(context):
"CLEVERAGENTS_DEFAULT_PROVIDER",
"CLEVERAGENTS_DEFAULT_MODEL",
"CLEVERAGENTS_OPENROUTER_ORGANIZATION",
# Database URL env vars: clear so scenarios testing the default or an
# explicit override start from a clean slate, regardless of what
# before_scenario injected for test isolation.
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
# CLEVERAGENTS_HOME affects database_url resolution; must be cleared
# alongside the URL vars so Settings() uses the true default path.
"CLEVERAGENTS_HOME",
"DATABASE_URL",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
@@ -59,6 +59,10 @@ def _extract_sqlite_path(url: str) -> Path | None:
Handles both ``sqlite:///path`` (relative) and
``sqlite:////abs/path`` (absolute) forms.
Note: intentionally duplicated in robot/helper_tdd_sqlite_url_cwd.py
to keep the Behave and Robot test suites independently runnable
without shared test utility coupling.
"""
prefix = "sqlite:///"
if not url.startswith(prefix):
-2
View File
@@ -15,14 +15,12 @@ Feature: TDD Issue #1024 — SQLite DB URL resolves to CWD instead of CLEVERAGEN
Bug: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/1024
TDD: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/1034
@tdd_issue @tdd_issue_4288 @tdd_expected_fail
Scenario: Default database_url resolves inside CLEVERAGENTS_HOME not CWD
Given a fresh CLEVERAGENTS_HOME temp directory for DB URL testing
When I resolve the effective database URL from Settings
Then the resolved database path should be inside CLEVERAGENTS_HOME
And the resolved database path should not be inside the original CWD
@tdd_issue @tdd_issue_4288 @tdd_expected_fail
Scenario: Settings database_url default resolves inside CLEVERAGENTS_HOME
Given a fresh CLEVERAGENTS_HOME temp directory for DB URL testing
When I resolve the Settings database_url default
+4 -4
View File
@@ -229,10 +229,10 @@ Setup Test Environment
Set Suite Variable ${PROJECT_NAME} test-project-${run_id}
Set Environment Variable CLEVERAGENTS_TESTING_USE_MOCK_AI true
Create Directory ${TEST_DIR}
${home}= Set Variable ${TEST_DIR}${/}.cleveragents_home
Run Keyword And Ignore Error Remove Directory ${home} recursive=True
Create Directory ${home}
Set Environment Variable CLEVERAGENTS_HOME ${home}
# Do NOT set CLEVERAGENTS_HOME here — individual test cases use
# separate project directories (cwd=...) and expect database files
# to be created relative to CWD, not a shared home directory.
Remove Environment Variable CLEVERAGENTS_HOME
Clean Core CLI Temp Dir
Run Keyword And Ignore Error Remove Directory ${TEST_DIR} recursive=True
+26 -13
View File
@@ -35,7 +35,12 @@ def _fail(message: str) -> NoReturn:
def _extract_sqlite_path(url: str) -> Path | None:
"""Extract the file path from a SQLite URL."""
"""Extract the file path from a SQLite URL.
Note: intentionally duplicated in features/steps/tdd_sqlite_url_cwd_steps.py
to keep the Robot and Behave test suites independently runnable
without shared test utility coupling.
"""
prefix = "sqlite:///"
if not url.startswith(prefix):
return None
@@ -113,8 +118,9 @@ def check_cli_db_location() -> None:
"""Verify that a CLI command creates the DB inside CLEVERAGENTS_HOME.
Invokes ``session list`` via the Typer CLI runner and then checks
whether any database files ended up inside CLEVERAGENTS_HOME rather
than the original CWD.
whether any NEW database files ended up in CWD rather than
CLEVERAGENTS_HOME. Records which files exist before the command
runs so pre-existing files from earlier test runs are not flagged.
"""
from typer.testing import CliRunner
@@ -133,23 +139,30 @@ def check_cli_db_location() -> None:
reset_container()
Settings._instance = None
runner = CliRunner()
runner.invoke(session_app, ["list"])
home = Path(tmpdir).resolve()
# Check for DB files in CWD that shouldn't be there
# Record which suspect files already exist before running the command,
# so we only flag files that are newly created by the CLI invocation.
suspect_files = [
original_cwd / "cleveragents.db",
original_cwd / "cleveragents_test.db",
original_cwd / ".cleveragents" / "db.sqlite",
]
found_in_cwd = [f for f in suspect_files if f.exists()]
pre_existing = {str(f) for f in suspect_files if f.exists()}
if found_in_cwd:
runner = CliRunner()
runner.invoke(session_app, ["list"])
home = Path(tmpdir).resolve()
# Check for NEW DB files in CWD that should not be there
newly_created = [
f for f in suspect_files if f.exists() and str(f) not in pre_existing
]
if newly_created:
_fail(
f"Database file(s) found in CWD instead of CLEVERAGENTS_HOME:\n"
f" CWD files: {found_in_cwd}\n"
f"Database file(s) newly created in CWD instead of "
f"CLEVERAGENTS_HOME:\n"
f" New CWD files: {newly_created}\n"
f" CLEVERAGENTS_HOME: {home}"
)
-2
View File
@@ -21,7 +21,6 @@ TDD SQLite DB URL Resolves Inside CLEVERAGENTS_HOME
[Documentation] Verify that ``get_database_url()`` resolves the database
... path inside CLEVERAGENTS_HOME when no explicit database URL
... is provided via environment variable.
[Tags] tdd_issue tdd_issue_1024 tdd_issue tdd_issue_4320 tdd_expected_fail
${result}= Run Process ${PYTHON} ${HELPER} check-db-url-resolution cwd=${WORKSPACE} timeout=60s on_timeout=kill
Log ${result.stdout}
@@ -32,7 +31,6 @@ TDD SQLite DB URL Resolves Inside CLEVERAGENTS_HOME
TDD CLI Command Creates DB Inside CLEVERAGENTS_HOME
[Documentation] Verify that a CLI command (session list) creates the
... database file inside CLEVERAGENTS_HOME, not in CWD.
[Tags] tdd_issue tdd_issue_1024 tdd_issue tdd_issue_4320 tdd_expected_fail
${result}= Run Process ${PYTHON} ${HELPER} check-cli-db-location cwd=${WORKSPACE} timeout=60s on_timeout=kill
Log ${result.stdout}
+54 -3
View File
@@ -170,6 +170,34 @@ class TuiServiceAdapters:
plan_service: PlanService
def _ensure_sqlite_parent_dir(database_url: str) -> None:
"""Create the parent directory for a SQLite database URL if needed.
Called by engine-building helpers so that the ``.cleveragents/``
directory (or whichever parent the resolved URL points at) exists
before SQLAlchemy attempts to create the file. This is separate
from ``get_database_url()`` to avoid eagerly creating the project
marker directory during URL resolution, which would break
``agents init`` detection.
Special SQLite URLs (e.g. ``:memory:``) are skipped since they
have no filesystem path. Errors during directory creation are
silently ignored this is a best-effort convenience; the
downstream engine/session will raise the proper error if the
path is truly unusable.
"""
prefix = "sqlite:///"
if not database_url.startswith(prefix):
return
raw_path = database_url[len(prefix) :]
if raw_path and not raw_path.startswith(":"):
try:
db_file = Path(raw_path)
db_file.parent.mkdir(parents=True, exist_ok=True)
except OSError:
pass
def get_ai_provider(
settings: Settings | None = None,
provider_registry: ProviderRegistry | None = None,
@@ -218,6 +246,9 @@ def get_database_url() -> str:
"""Get the database URL with proper path handling.
Prefers explicit test overrides to keep CI fast and non-interactive.
When no explicit URL is set, resolves the default database path
relative to ``CLEVERAGENTS_HOME`` (if set) rather than CWD, ensuring
test isolation and correct data placement.
Returns:
SQLAlchemy database URL with absolute path for SQLite
@@ -232,9 +263,20 @@ def get_database_url() -> str:
if env_url:
return env_url
# Fallback to file-based SQLite in the current working directory
db_path = Path.cwd() / ".cleveragents" / "db.sqlite"
# Don't create directory here - let ProjectService handle it
# Resolve relative to CLEVERAGENTS_HOME when set, otherwise CWD.
# This ensures that database files live inside the project home
# directory rather than wherever the process happens to be invoked,
# which is critical for test isolation and multi-project setups.
home = os.environ.get("CLEVERAGENTS_HOME")
base_dir = Path(home) if home else Path.cwd()
db_path = base_dir / ".cleveragents" / "db.sqlite"
# Do NOT eagerly create the parent directory here. The
# ``.cleveragents/`` directory is the project marker checked by
# ``agents init``; creating it prematurely causes init to report
# "Project already initialized" when CLEVERAGENTS_HOME == CWD.
# Directory creation is handled by ``agents init`` and by the
# engine-building helpers (_build_session_service, etc.) that
# call ``Base.metadata.create_all()`` after ensuring the schema.
# SQLite requires absolute path with 4 slashes for file URLs
return f"sqlite:///{db_path.absolute()}"
@@ -254,6 +296,7 @@ def _build_repo_indexing_service(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return RepoIndexingService(session_factory=factory)
@@ -268,6 +311,7 @@ def _build_session_factory(database_url: str) -> sessionmaker[Session]:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
return sessionmaker(bind=engine, expire_on_commit=False)
@@ -297,6 +341,7 @@ def _build_resource_registry_service(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return ResourceRegistryService(session_factory=factory)
@@ -309,6 +354,7 @@ def _build_namespaced_project_repo(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return NamespacedProjectRepository(session_factory=factory)
@@ -321,6 +367,7 @@ def _build_project_resource_link_repo(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return ProjectResourceLinkRepository(session_factory=factory)
@@ -339,6 +386,7 @@ def _build_checkpoint_service(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
repository = CheckpointRepository(session_factory=factory)
@@ -390,6 +438,7 @@ def _build_trace_service(
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
repository = LLMTraceRepository(session_factory=factory)
@@ -436,6 +485,7 @@ def _build_skill_service(
SkillRepository,
)
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
# Targeted table creation — only skill tables, never the full
@@ -547,6 +597,7 @@ def _build_session_service(
SessionModel,
)
_ensure_sqlite_parent_dir(database_url)
engine = create_engine(database_url, echo=False)
# Targeted table creation — only session tables, never the full schema.
+10 -4
View File
@@ -218,9 +218,15 @@ def _check_database() -> dict[str, Any]:
"status": CheckStatus.OK if writable else CheckStatus.ERROR,
"details": "writable" if writable else "locked or not writable",
}
# DB file doesn't exist yet — that's OK for SQLite (created on first use)
parent = db_path.parent
if parent.exists() and os.access(parent, os.W_OK):
# DB file doesn't exist yet — that's OK for SQLite (created on first use).
# Allow at most one missing intermediate directory (e.g. .cleveragents/
# before ``agents init`` runs). Do NOT walk all the way to the
# filesystem root — that would return OK for completely invalid paths
# when running as root (e.g. in CI).
ancestor = db_path.parent
if not ancestor.exists():
ancestor = ancestor.parent
if ancestor.exists() and os.access(ancestor, os.W_OK):
return {
"name": "Database",
"status": CheckStatus.OK,
@@ -229,7 +235,7 @@ def _check_database() -> dict[str, Any]:
return {
"name": "Database",
"status": CheckStatus.ERROR,
"details": f"parent dir not writable ({parent})",
"details": f"parent dir not writable ({ancestor})",
}
return {
+82 -2
View File
@@ -29,6 +29,33 @@ class ProviderDefaults:
model_source: str
def _resolve_sqlite_url(url: str, base_dir: Path) -> str:
"""Resolve a relative SQLite URL to an absolute path under *base_dir*.
Only modifies ``sqlite:///`` URLs whose path component is relative.
Absolute paths, non-SQLite URLs, and special SQLite connection
strings (e.g. ``:memory:``) are returned unchanged.
Note: No path containment validation database_url is admin-configured,
not untrusted user input. Paths with ``../`` could resolve outside
CLEVERAGENTS_HOME.
"""
prefix = "sqlite:///"
if not url.startswith(prefix):
return url
raw_path = url[len(prefix) :]
if not raw_path:
return url
# Preserve special SQLite connection strings like :memory:
if raw_path.startswith(":"):
return url
p = Path(raw_path)
if p.is_absolute():
return url
resolved = (base_dir / p).resolve()
return f"sqlite:///{resolved}"
class Settings(BaseSettings):
"""Application runtime configuration backed by environment variables."""
@@ -330,13 +357,31 @@ class Settings(BaseSettings):
# Persistence
database_url: str = Field(
default_factory=lambda: (
f"sqlite:///{Path.home() / '.cleveragents' / 'cleveragents.db'}"
"sqlite:///"
+ str(
Path(
os.environ.get(
"CLEVERAGENTS_HOME",
str(Path.home() / ".cleveragents"),
)
)
/ "cleveragents.db"
)
),
validation_alias=AliasChoices("CLEVERAGENTS_DATABASE_URL"),
)
test_database_url: str = Field(
default_factory=lambda: (
f"sqlite:///{Path.home() / '.cleveragents' / 'cleveragents_test.db'}"
"sqlite:///"
+ str(
Path(
os.environ.get(
"CLEVERAGENTS_HOME",
str(Path.home() / ".cleveragents"),
)
)
/ "cleveragents_test.db"
)
),
validation_alias=AliasChoices("CLEVERAGENTS_TEST_DATABASE_URL"),
)
@@ -673,6 +718,41 @@ class Settings(BaseSettings):
),
)
@model_validator(mode="after")
def _resolve_database_urls(self) -> Settings:
"""Resolve relative SQLite database URLs against CLEVERAGENTS_HOME.
The default ``database_url`` is ``sqlite:///cleveragents.db`` a
relative path. Without resolution this would create the DB file
relative to CWD, breaking test isolation when
``CLEVERAGENTS_HOME`` points elsewhere. This validator rewrites
relative SQLite paths to be absolute under ``CLEVERAGENTS_HOME``
(if set) or CWD (as a fallback).
Resolution is **skipped** when the corresponding env var
(``CLEVERAGENTS_DATABASE_URL`` / ``CLEVERAGENTS_TEST_DATABASE_URL``)
is explicitly set to a non-empty value. In that case the caller
chose a specific URL intentionally and it must be honoured as-is.
"""
home_env = os.environ.get("CLEVERAGENTS_HOME")
base_dir = Path(home_env) if home_env else Path.cwd()
for attr, env_key in (
("database_url", "CLEVERAGENTS_DATABASE_URL"),
("test_database_url", "CLEVERAGENTS_TEST_DATABASE_URL"),
):
# If the caller explicitly provided this URL via env var, use
# it as-is. Only resolve DEFAULT values (factory-generated or
# bare relative paths that the user did not deliberately set).
if os.environ.get(env_key):
continue
url: str = getattr(self, attr)
resolved = _resolve_sqlite_url(url, base_dir)
if resolved != url:
object.__setattr__(self, attr, resolved)
return self
@model_validator(mode="after")
def _max_delay_ge_base_delay(self) -> Settings:
"""Ensure retry_max_delay >= retry_base_delay."""