From e7d8e1a07b4532b56e49242b3eda435863ef858c Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 28 Jul 2026 11:12:32 +0200 Subject: [PATCH] Centralize machine-local path resolution in nerve.paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every machine-local directory now comes from one provider instead of ~/.nerve literals scattered across config, cli, bootstrap, pairing, cron gates and the engine. NERVE_HOME is honoured and normalized to an absolute path, so a relative value cannot silently resolve against whatever directory the process started in, and Docker artifacts are routed through the provider rather than hardcoded. CLI PID and log paths are resolved lazily: computing them at import time froze them before NERVE_HOME could take effect. A blank path setting now means unset rather than the working directory. Path("") is Path("."), which is truthy, so every `or ` fallback was skipped and a blank value silently resolved to the daemon's cwd — including gateway.ssl.cert, where it made SSLConfig.enabled true with "." as the certificate. Blank, whitespace-only, and blank-after-expansion all return None. The path settings that stay str carry the same rule separately, because they are expanded downstream and so never reach that helper: the codex origin path and archive_path, codex home_dir, backup target_dir, cron prompt_file and the Telegram session_path. The `or ` idiom alone catches "" and a missing key but not " ", which is truthy and became a directory named for a space, relative to wherever the process started. Two of these are worse than a stray directory: prompt_file outranks an inline prompt, so a blank one failed every run while a usable prompt sat unused; and the Telegram session is the authenticated login, so the interactive sync wrote one file while cron looked for another and reported only that it was not authenticated. Co-Authored-By: Claude --- docs/config.md | 5 + docs/setup.md | 6 +- nerve/agent/tools/handlers/memory.py | 3 +- nerve/bootstrap.py | 106 ++++--- nerve/cli.py | 90 +++--- nerve/config.py | 117 +++++--- nerve/cron/gates.py | 4 +- nerve/cron/jobs.py | 5 +- nerve/db/__init__.py | 3 +- nerve/gateway/routes/external_agents.py | 8 +- nerve/gateway/server.py | 16 +- nerve/memory/memu_bridge.py | 5 +- nerve/pairing.py | 5 +- nerve/paths.py | 171 +++++++++++ nerve/proxy/service.py | 4 +- nerve/sources/telegram.py | 19 +- tests/conftest.py | 69 +++-- tests/test_bootstrap.py | 101 ++++++- tests/test_config_resolution.py | 147 +++++++++- tests/test_cron.py | 19 ++ tests/test_paths.py | 375 ++++++++++++++++++++++++ tests/test_proxy.py | 9 +- tests/test_resume_after_restart.py | 66 ++++- tests/test_telegram_source.py | 46 +++ 24 files changed, 1227 insertions(+), 172 deletions(-) create mode 100644 nerve/paths.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_telegram_source.py diff --git a/docs/config.md b/docs/config.md index 74deb176..5717937e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -8,6 +8,11 @@ Values in `config.local.yaml` are deep-merged on top of `config.yaml`. Unknown keys are ignored but logged as warnings at startup (and shown by `nerve doctor`) so typos don't fail silently. +Keys typed `path` below expand `~` and any environment variables that are set. +A blank value (`runs_dir:` with nothing after it, or `""`) means *unset*, so the +documented default applies. That includes `gateway.ssl.cert`/`key`: blank means +TLS is off, not TLS with an empty certificate path. + ## Config Directory Resolution `nerve` commands locate the config directory via a waterfall, so they work diff --git a/docs/setup.md b/docs/setup.md index bcc45af2..8d766794 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -92,7 +92,11 @@ Set `NERVE_USE_PROXY=1` in your environment (no `ANTHROPIC_API_KEY` required). T |-------|---------| | `.:/nerve` | Application code (bind mount) | | `nerve-data:/root/.nerve` | Databases, logs, PID, sessions | -| `nerve-workspace:/root/nerve-workspace` | Workspace files (SOUL.md, tasks, skills) | +| `nerve-workspace:/root/nerve-workspace` | Workspace files (SOUL.md, tasks, skills, `config/`) | + +The container paths are not conventions — the generated Dockerfile sets +`NERVE_HOME=/root/.nerve` and `NERVE_WORKSPACE=/root/nerve-workspace` +explicitly. Point them elsewhere and the mounts must follow. ## Re-running `nerve init` diff --git a/nerve/agent/tools/handlers/memory.py b/nerve/agent/tools/handlers/memory.py index 94526940..fe36b3b9 100644 --- a/nerve/agent/tools/handlers/memory.py +++ b/nerve/agent/tools/handlers/memory.py @@ -14,6 +14,7 @@ import time from pathlib import Path +from nerve import paths from nerve.agent.tools.registry import ToolContext, ToolResult, ToolSpec from nerve.agent.tools.schemas import ( CATEGORY_UPDATE_SCHEMA, @@ -404,7 +405,7 @@ async def memorize_handler(ctx: ToolContext, args: dict) -> ToolResult: memu_err: str | None = None memu_down = False # transient backend outage (distinct from a genuine failure) try: - mem_dir = Path("~/.nerve/memu-manual").expanduser() + mem_dir = paths.nerve_path("memu-manual") mem_path = mem_dir / f"memorize-{int(time.time())}.txt" def _write_memorize_file() -> None: diff --git a/nerve/bootstrap.py b/nerve/bootstrap.py index 1d7b854e..058db1a1 100644 --- a/nerve/bootstrap.py +++ b/nerve/bootstrap.py @@ -20,6 +20,7 @@ import click import yaml +from nerve import paths from nerve.workspace import initialize_workspace, install_bundled_skills @@ -423,7 +424,9 @@ def _check_openai_key(key: str, timeout: float = 7.0) -> tuple[bool, str]: # to start over. Answers are now checkpointed after every step so an # interrupted setup resumes where it left off. -INIT_STATE_FILE = Path("~/.nerve/init-state.json") +def _init_state_file() -> Path: + """Wizard checkpoint file under the machine-local state dir.""" + return paths.nerve_path("init-state.json") def _save_init_state(choices: SetupChoices, completed: set[str]) -> None: @@ -439,7 +442,7 @@ def _save_init_state(choices: SetupChoices, completed: set[str]) -> None: "completed": sorted(completed), "saved_at": datetime.now().isoformat(timespec="seconds"), } - path = INIT_STATE_FILE.expanduser() + path = _init_state_file() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(state), encoding="utf-8") os.chmod(path, 0o600) # contains API keys @@ -450,14 +453,14 @@ def _save_init_state(choices: SetupChoices, completed: set[str]) -> None: def _load_init_state() -> dict | None: try: return json.loads( - INIT_STATE_FILE.expanduser().read_text(encoding="utf-8") + _init_state_file().read_text(encoding="utf-8") ) except (FileNotFoundError, json.JSONDecodeError, OSError): return None def _clear_init_state() -> None: - INIT_STATE_FILE.expanduser().unlink(missing_ok=True) + _init_state_file().unlink(missing_ok=True) def _choices_from_dict(data: dict) -> SetupChoices: @@ -1065,10 +1068,13 @@ def _step_proxy_setup(self) -> None: self.choices.use_proxy = False self._step_api_key_direct() else: + # The operator is meant to paste this, so the paths have to be the + # ones the proxy service actually uses (nerve/proxy/service.py). click.secho( "\n You can authenticate later by running:\n" - " ~/.nerve/bin/cli-proxy-api --claude-login --no-browser \\\n" - " --config ~/.nerve/cli-proxy-config.yaml", + f" {paths.path_label('bin', 'cli-proxy-api')}" + " --claude-login --no-browser \\\n" + f" --config {paths.path_label('cli-proxy-config.yaml')}", dim=True, ) @@ -1102,13 +1108,13 @@ def _step_workspace(self) -> None: click.secho(" TOOLS.md — Environment-specific notes", dim=True) click.echo() - default_ws = "/root/nerve-workspace" if self._inside_docker else "~/nerve-workspace" + default_ws = _DOCKER_WORKSPACE if self._inside_docker else "~/nerve-workspace" ws = click.prompt("Workspace path", default=default_ws) self.choices.workspace_path = Path(ws) click.echo() click.secho( " Nerve also stores databases, logs, and session data in\n" - " ~/.nerve/ — this is separate from your workspace.", + f" {paths.home_label()}/ — this is separate from your workspace.", dim=True, ) click.echo() @@ -1676,9 +1682,9 @@ def _apply(self) -> None: self._write_config_local_yaml() click.secho(" ✓", fg="green") - # 9. Create ~/.nerve directory structure - click.echo(" Setting up ~/.nerve/...", nl=False) - nerve_dir = Path("~/.nerve").expanduser() + # 9. Create the machine-local state directory + click.echo(f" Setting up {paths.home_label()}/...", nl=False) + nerve_dir = paths.nerve_home() nerve_dir.mkdir(parents=True, exist_ok=True) (nerve_dir / "cron").mkdir(parents=True, exist_ok=True) click.secho(" ✓", fg="green") @@ -1941,10 +1947,9 @@ def _write_config_yaml(self) -> None: else _WORKER_MEMORY_CATEGORIES ), }, - "cron": { - "system_file": "~/.nerve/cron/system.yaml", - "jobs_file": "~/.nerve/cron/jobs.yaml", - }, + # No "cron" block: CronConfig already defaults to paths.cron_dir(). + # Pinning literal ~/.nerve paths here overrode NERVE_HOME and sent + # the daemon looking somewhere `nerve init` had not written. "sessions": { "sticky_period_minutes": 120, "archive_after_days": 30, @@ -2127,8 +2132,12 @@ def _write_cron_jobs(self) -> None: job["run_if"] = cron["run_if"] jobs.append(job) - # Write system crons (managed by nerve init, safe to regenerate) - system_file = Path("~/.nerve/cron/system.yaml").expanduser() + # Write system crons (managed by nerve init, safe to regenerate). + # Must go through paths.cron_dir() -- a literal ~/.nerve here ignores + # NERVE_HOME, so a relocated instance writes its jobs into the default + # state dir while the daemon reads them from the relocated one. + cron_dir = paths.cron_dir() + system_file = cron_dir / "system.yaml" system_file.parent.mkdir(parents=True, exist_ok=True) with open(system_file, "w") as f: @@ -2138,7 +2147,7 @@ def _write_cron_jobs(self) -> None: yaml.safe_dump({"jobs": jobs}, f, default_flow_style=False, sort_keys=False) # Create empty jobs.yaml scaffold if it doesn't exist - jobs_file = Path("~/.nerve/cron/jobs.yaml").expanduser() + jobs_file = cron_dir / "jobs.yaml" if not jobs_file.exists(): with open(jobs_file, "w") as f: f.write("# Nerve — Custom Cron Jobs\n") @@ -2313,7 +2322,7 @@ def run_non_interactive(config_dir: Path) -> SetupChoices: # Optional choices.mode = os.environ.get("NERVE_MODE", "personal") choices.openai_api_key = os.environ.get("OPENAI_API_KEY", "") - default_ws = "/root/nerve-workspace" if is_docker else "~/nerve-workspace" + default_ws = _DOCKER_WORKSPACE if is_docker else "~/nerve-workspace" choices.workspace_path = Path(os.environ.get("NERVE_WORKSPACE", default_ws)) choices.timezone = os.environ.get("NERVE_TIMEZONE", "America/New_York") choices.telegram_bot_token = os.environ.get("NERVE_TELEGRAM_BOT_TOKEN", "") @@ -2420,6 +2429,23 @@ def _wrap_text(text: str, width: int = 51) -> list[str]: # --- Docker file templates --- +# Container-side locations, referenced by the Dockerfile, the compose mounts and +# the agent-facing docs below so they can't drift apart. The image sets both as +# environment variables rather than relying on the root user's $HOME happening +# to be /root — see nerve.paths.nerve_home() for how NERVE_HOME is consumed. +_DOCKER_NERVE_HOME = "/root/.nerve" +_DOCKER_WORKSPACE = "/root/nerve-workspace" + + +def _compose_host_state_dir() -> str: + """Host-side path to mount as the container's state dir. + + Uses the raw ``NERVE_HOME`` value rather than the expanded one: compose + expands ``~`` itself, so echoing what the operator wrote keeps the + generated file portable between machines. + """ + return os.environ.get(paths.NERVE_HOME_ENV, "").strip() or "~/.nerve" + _DOCKERFILE_TEMPLATE = """ FROM python:3.13-slim @@ -2442,11 +2468,13 @@ def _wrap_text(text: str, width: int = 51) -> list[str]: && ARCH=$(dpkg --print-architecture) \\ && curl -fsSL "https://github.com/steipete/gogcli/releases/download/v${GOG_VERSION}/gogcli_${GOG_VERSION}_linux_${ARCH}.tar.gz" \\ | tar xz -C /usr/local/bin gog - -RUN mkdir -p /root/.nerve /root/nerve-workspace +""" + f""" +RUN mkdir -p {_DOCKER_NERVE_HOME} {_DOCKER_WORKSPACE} ENV NERVE_DOCKER=1 - +ENV NERVE_HOME={_DOCKER_NERVE_HOME} +ENV NERVE_WORKSPACE={_DOCKER_WORKSPACE} +""" + """ WORKDIR /nerve # Pre-install Python dependencies for caching @@ -2483,11 +2511,16 @@ def _build_docker_compose( # sdk_session_id rows fail every --resume with "No conversation found" # exit 1. Siloed under ~/.nerve so the agent's CLI is isolated from # the host user's personal ~/.claude (where macOS stores OAuth tokens). + # The host side follows NERVE_HOME too. Deriving only the container side + # would mount the default ~/.nerve into an instance the operator had + # deliberately relocated, giving the host and the container two different + # databases with no indication anything was wrong. + host_state = _compose_host_state_dir() volumes = [ ".:/nerve", - "~/.nerve:/root/.nerve", - "~/.nerve/claude:/root/.claude", - f"{workspace_path}:/root/nerve-workspace", + f"{host_state}:{_DOCKER_NERVE_HOME}", + f"{host_state}/claude:/root/.claude", + f"{workspace_path}:{_DOCKER_WORKSPACE}", ] # Optional auth mounts — only include if the host directory exists. @@ -2568,8 +2601,10 @@ def _build_docker_compose( mkdir -p /root/.claude chmod 700 /root/.claude -# Clean up stale PID file from previous container runs -rm -f ~/.nerve/nerve.pid +# Clean up stale PID file from previous container runs. The Dockerfile sets +# NERVE_HOME; the fallback keeps this working if the entrypoint is reused +# somewhere that doesn't. +rm -f "${NERVE_HOME:-$HOME/.nerve}/nerve.pid" # If no arguments, default to init + start if [ $# -eq 0 ]; then @@ -2580,29 +2615,32 @@ def _build_docker_compose( fi """ -_DOCKER_TOOLS_SECTION = """ +_DOCKER_TOOLS_SECTION = f""" ## Docker Environment You are running inside a Docker container. Key paths: | Path | Contents | Writable | Notes | |------|----------|----------|-------| -| `/nerve` | Nerve source code | ✓ (bind mount) | `pyproject.toml`, `nerve/` package, `web/` — the full repo. Changes persist to host. | -| `/root/.nerve` | Config directory | ✓ (bind mount) | `config.yaml`, `config.local.yaml`, `cron/` jobs. | -| `/root/nerve-workspace` | Your workspace | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, etc. — where you live. | +| `/nerve` | Nerve source code **and config** | ✓ (bind mount) | `pyproject.toml`, `nerve/` package, `web/` — the full repo. The config directory resolves to the working directory, so `config.yaml` and `config.local.yaml` live here. | +| `{_DOCKER_NERVE_HOME}` | Machine-local state (`$NERVE_HOME`) | ✓ (bind mount) | Databases, logs, PID, caches, `cron/` jobs. Never synced. | +| `{_DOCKER_WORKSPACE}` | Your workspace (`$NERVE_WORKSPACE`) | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, skills — where you live. | ### Working with Nerve source - The Nerve package is installed in editable mode (`pip install -e /nerve`). - After modifying Nerve source code, changes take effect immediately for Python. - If you modify the web UI (`/nerve/web/`), rebuild with: `cd /nerve/web && npm run build` -- Config files live in `/root/.nerve/`, NOT in `/nerve/`. +- Config files live in `/nerve/`, NOT in `{_DOCKER_NERVE_HOME}/` — the config + directory is the working directory the daemon was started from. +- Cron jobs live in `{_DOCKER_NERVE_HOME}/cron/` (`jobs.yaml`, `system.yaml`, `gates/`). ### Credentials Docker can't access the host's macOS Keychain. Credentials are extracted during -`nerve init` and stored in `/root/.nerve/config.local.yaml`. The entrypoint exports -them as environment variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `GH_TOKEN`). +`nerve init` and stored in `/nerve/config.local.yaml` (alongside `config.yaml`). +The entrypoint reads that file and exports them as environment variables +(`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `GH_TOKEN`). """ _DOCKERIGNORE_TEMPLATE = """ diff --git a/nerve/cli.py b/nerve/cli.py index 9d8101ad..44eaa3a7 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -30,6 +30,7 @@ import click +from nerve import paths from nerve.config import ( RESUME_QUEUE_FILE, load_config, @@ -38,10 +39,9 @@ write_config_pointer, ) -# Default PID file location -PID_DIR = Path("~/.nerve").expanduser() -PID_FILE = PID_DIR / "nerve.pid" -LOG_FILE = PID_DIR / "nerve.log" +# PID / log file locations resolve lazily through nerve.paths so that a +# NERVE_HOME override (e.g. in tests or multi-instance setups) is always +# honored — nothing is frozen at import time. def setup_logging(verbose: bool = False) -> None: @@ -62,7 +62,7 @@ def setup_logging(verbose: bool = False) -> None: def _read_pid() -> int | None: """Read PID from file. Returns None if no valid PID file.""" try: - pid = int(PID_FILE.read_text().strip()) + pid = int(paths.pid_file().read_text().strip()) return pid except (FileNotFoundError, ValueError): return None @@ -80,14 +80,14 @@ def _is_running(pid: int) -> bool: def _write_pid(pid: int, config_dir: Path | None = None) -> None: - PID_DIR.mkdir(parents=True, exist_ok=True) - PID_FILE.write_text(str(pid)) + paths.nerve_home().mkdir(parents=True, exist_ok=True) + paths.pid_file().write_text(str(pid)) if config_dir is not None: write_config_pointer(config_dir) def _remove_pid() -> None: - PID_FILE.unlink(missing_ok=True) + paths.pid_file().unlink(missing_ok=True) def _get_daemon_status() -> tuple[bool, int | None]: @@ -294,9 +294,9 @@ def start(ctx: click.Context, foreground: bool) -> None: cmd.extend(["start", "--foreground"]) # Ensure log directory exists - PID_DIR.mkdir(parents=True, exist_ok=True) + paths.nerve_home().mkdir(parents=True, exist_ok=True) - log_fd = open(LOG_FILE, "a") + log_fd = open(paths.log_file(), "a") proc = subprocess.Popen( cmd, stdout=log_fd, @@ -310,13 +310,13 @@ def start(ctx: click.Context, foreground: bool) -> None: time.sleep(1) if proc.poll() is not None: click.echo(f"Nerve failed to start (exit code {proc.returncode})") - click.echo(f"Check logs: {LOG_FILE}") + click.echo(f"Check logs: {paths.log_file()}") ctx.exit(1) return click.echo(f"Nerve started (PID {proc.pid})") click.echo(f" Listening on {config.gateway.host}:{config.gateway.port}") - click.echo(f" Logs: {LOG_FILE}") + click.echo(f" Logs: {paths.log_file()}") @main.command() @@ -440,8 +440,8 @@ def restart(ctx: click.Context, resume_ids: tuple[str, ...]) -> None: helper_script = ( "import os, signal, subprocess, sys, time\n" f"old_pid = {old_pid if running else 'None'}\n" - f"pid_file = {str(PID_FILE)!r}\n" - f"log_file = {str(LOG_FILE)!r}\n" + f"pid_file = {str(paths.pid_file())!r}\n" + f"log_file = {str(paths.log_file())!r}\n" f"start_cmd = {start_cmd_parts!r}\n" "if old_pid is not None:\n" " try:\n" @@ -480,7 +480,7 @@ def restart(ctx: click.Context, resume_ids: tuple[str, ...]) -> None: " sys.exit(1)\n" ) - log_fd = open(LOG_FILE, "a") + log_fd = open(paths.log_file(), "a") subprocess.Popen( [sys.executable, "-c", helper_script], stdout=log_fd, @@ -549,14 +549,14 @@ def status(ctx: click.Context, follow: bool) -> None: config = ctx.obj["config"] click.echo(f" Listening on {config.gateway.host}:{config.gateway.port}") - click.echo(f" Logs: {LOG_FILE}") + click.echo(f" Logs: {paths.log_file()}") else: click.echo("Nerve is not running") - if follow and LOG_FILE.exists(): - click.echo(f"\n--- Tailing {LOG_FILE} ---") + if follow and paths.log_file().exists(): + click.echo(f"\n--- Tailing {paths.log_file()} ---") try: - os.execlp("tail", "tail", "-f", str(LOG_FILE)) + os.execlp("tail", "tail", "-f", str(paths.log_file())) except Exception: click.echo("Cannot tail log file") @@ -573,16 +573,16 @@ def logs(ctx: click.Context) -> None: _docker_compose(config_dir, ["logs", "-f"], replace_process=True) return # unreachable - if not LOG_FILE.exists(): - click.echo(f"No log file at {LOG_FILE}") + if not paths.log_file().exists(): + click.echo(f"No log file at {paths.log_file()}") return - click.echo(f"--- {LOG_FILE} ---") + click.echo(f"--- {paths.log_file()} ---") try: - os.execlp("tail", "tail", "-f", str(LOG_FILE)) + os.execlp("tail", "tail", "-f", str(paths.log_file())) except Exception: # Fallback: print last 50 lines - lines = LOG_FILE.read_text().splitlines() + lines = paths.log_file().read_text().splitlines() for line in lines[-50:]: click.echo(line) @@ -693,13 +693,21 @@ def upgrade(ctx: click.Context, no_frontend: bool, no_deps: bool, no_pull: bool) click.echo(" nerve restart") -_CONFIG_SOURCE_LABELS = { - "flag": "--config-dir flag", - "env": "NERVE_CONFIG_DIR env var", - "cwd": "current directory", - "pointer": "~/.nerve/config_dir pointer", - "default": "current directory (no config found anywhere)", -} +def _config_source_label(source: str) -> str: + """Human-readable name for where the config directory was discovered. + + A function rather than a module-level dict because the pointer label names + a machine-local path: baked in at import time it would report whatever + ``NERVE_HOME`` said before the process was fully set up, so doctor output + could point the reader at a file that is not the one being consulted. + """ + return { + "flag": "--config-dir flag", + "env": "NERVE_CONFIG_DIR env var", + "cwd": "current directory", + "pointer": f"{paths.config_pointer_file()} pointer", + "default": "current directory (no config found anywhere)", + }.get(source, source) def _check_api_connectivity(config) -> tuple[bool, str]: @@ -742,7 +750,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s # Where the config came from — surfaces CWD mixups immediately config_dir = getattr(config, "config_dir", None) if config_dir is not None: - source_label = _CONFIG_SOURCE_LABELS.get(config_source, config_source) + source_label = _config_source_label(config_source) suffix = f" (via {source_label})" if source_label else "" has_base = (Path(config_dir) / "config.yaml").exists() has_local = (Path(config_dir) / "config.local.yaml").exists() @@ -910,7 +918,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s warnings.append("[WARN] JWT secret not set — running in dev mode") # Check DB - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() if db_path.exists(): lines.append(f"[OK] Database: {db_path} ({db_path.stat().st_size / 1024:.1f} KB)") else: @@ -986,7 +994,7 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s else: lines.append( "[--] Backups not configured — set backup.target_dir + enabled to " - "protect ~/.nerve against disk loss" + f"protect {paths.home_label()} against disk loss" ) # Check external tools @@ -1221,7 +1229,7 @@ def setup_telegram(ctx: click.Context) -> None: ctx.exit(1) return - session_path = os.path.expanduser("~/.nerve/telegram_sync") + session_path = str(paths.nerve_path("telegram_sync")) click.echo(f"Telethon session: {session_path}.session") click.echo(f"API ID: {api_id}") click.echo() @@ -1322,7 +1330,7 @@ def backup(ctx: click.Context, output: str | None, state_only: bool, no_secrets: "in config.yaml (an external mount or synced dir is recommended)." ) - nerve_dir = PID_DIR + nerve_dir = paths.nerve_home() include_workspace = config.backup.include_workspace and not state_only if not quiet: @@ -1381,7 +1389,7 @@ def restore(ctx: click.Context, bundle: str, verify_only: bool, force: bool) -> if not bundle_path.is_file(): raise click.ClickException(f"Bundle not found: {bundle_path}") - nerve_dir = PID_DIR + nerve_dir = paths.nerve_home() if verify_only: try: @@ -1448,7 +1456,7 @@ def migrate_openclaw(ctx: click.Context, sessions_dir: str, dry_run: bool, min_m config = ctx.obj["config"] sessions_path = Path(sessions_dir).expanduser() - conv_dir = Path("~/.nerve/memu-conversations").expanduser() + conv_dir = paths.nerve_path("memu-conversations") if not sessions_path.exists(): click.echo(f"[ERR] Sessions directory not found: {sessions_path}") @@ -1695,7 +1703,7 @@ def db_prune(ctx: click.Context, dry_run: bool) -> None: from nerve.db import Database config = ctx.obj["config"] - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() if not db_path.exists(): click.echo(f"[ERR] Database not found: {db_path}") ctx.exit(1) @@ -1750,7 +1758,7 @@ def db_vacuum(ctx: click.Context) -> None: from nerve.db import Database config = ctx.obj["config"] - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() if not db_path.exists(): click.echo(f"[ERR] Database not found: {db_path}") ctx.exit(1) @@ -1803,7 +1811,7 @@ def _format_workflow_run(run: dict) -> str: def _workflow_db_path(ctx: click.Context) -> Path: - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() if not db_path.exists(): click.echo(f"[ERR] Database not found: {db_path}") ctx.exit(1) diff --git a/nerve/config.py b/nerve/config.py index 86ab436d..5a9e5853 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -14,15 +14,20 @@ from pathlib import Path from typing import Any +from nerve import paths + import yaml logger = logging.getLogger(__name__) # Runtime queue of session ids to auto-resume after ``nerve restart --resume``. # The CLI appends ids here (synchronously, before triggering the restart) and -# the fresh daemon drains it on startup. Kept next to the other ~/.nerve runtime -# state (pid/log/db) and independent of ``-c`` so writer and reader always agree. -RESUME_QUEUE_FILE = Path("~/.nerve/resume-after-restart").expanduser() +# the fresh daemon drains it on startup. Kept next to the other machine-local +# runtime state (pid/log/db) and independent of ``-c`` so writer and reader +# always agree — which is why it goes through the path provider rather than a +# literal home: with NERVE_HOME set, a hardcoded path would have the CLI write +# one file and the daemon read another. +RESUME_QUEUE_FILE = paths.nerve_path("resume-after-restart") def _deep_merge(base: dict, override: dict) -> dict: @@ -37,9 +42,55 @@ def _deep_merge(base: dict, override: dict) -> dict: def _expand_path(p: str | None) -> Path | None: + """Expand a configured path; a blank value means "not set" (``None``). + + Blank has to mean unset because ``Path("")`` is ``Path(".")``, which is + *truthy*. Callers spell their defaults as ``_expand_path(...) or + ``, so a key written ``runs_dir: ""`` — or left as a bare + ``runs_dir:`` with a stray space after it — would otherwise sail past the + fallback and quietly mean "the directory the daemon was started in": run + journals, proxy auth material, and the gate-plugin directory whose ``*.py`` + files get imported and executed, all landing somewhere nobody chose, with + nothing logged to say so. ``gateway.ssl`` is sharper still because it has no + fallback at all — ``enabled`` is "cert and key are both set", so a blank + cert would switch TLS *on* with ``.`` as the certificate file. Blank there + means TLS off. + + Expansion covers ``~`` and environment variables that are set, and the + blank check is applied to what comes back out as well: a variable that is + set but *empty* (``NERVE_RUNS_DIR=`` in a unit file or a compose env block) + expands to nothing and would land right back on ``Path(".")``. An unset + ``$VAR`` is a different case — ``expandvars`` leaves it in the string + verbatim, which yields a literal, obviously-wrong path instead of another + silent "unset", and that is the more useful failure. Stripping happens + before ``expanduser`` because ``expanduser`` only expands a leading ``~``, + so ``" ~/runs"`` would otherwise keep its tilde. + """ if p is None: return None - return Path(os.path.expanduser(os.path.expandvars(str(p)))) + expanded = os.path.expanduser(os.path.expandvars(str(p).strip())).strip() + if not expanded: + return None + return Path(expanded) + + +def _setting_str(value: object, default: str = "") -> str: + """The same "blank means unset" rule for path settings that stay ``str``. + + A few settings are declared ``str`` rather than ``Path`` because something + downstream expands them instead, so they never reach :func:`_expand_path` + and need the rule spelled out again. Writing it once keeps the two from + drifting: the ``or `` idiom on its own catches ``""`` and a missing + key but not ``" "``, which is truthy and survives to become a directory + named for a space, created relative to wherever the process happened to + start. Where there is no default, ``""`` *is* the unset value and callers + gate on it, so whitespace would read as deliberately configured. + + Non-strings are stringified rather than rejected, matching the surrounding + ``str(d.get(...))`` calls; a wrong *type* is a separate concern from a blank + value, and it surfaces as an obviously-wrong path rather than a silent one. + """ + return str(value or "").strip() or default @dataclass @@ -494,8 +545,10 @@ def from_dict(cls, d: dict) -> CodexOriginConfig: id=d.get("id", "local"), type=d.get("type", "local_rollout"), enabled=bool(d.get("enabled", True)), - path=d.get("path", "~/.codex/sessions"), - archive_path=d.get("archive_path", "~/.codex/archived_sessions"), + path=_setting_str(d.get("path"), "~/.codex/sessions"), + archive_path=_setting_str( + d.get("archive_path"), "~/.codex/archived_sessions" + ), poll_interval_seconds=float(d.get("poll_interval_seconds", 2.0)), transport=d.get("transport", {}), ) @@ -606,7 +659,7 @@ class MemoryConfig: @classmethod def from_dict(cls, d: dict) -> MemoryConfig: - default_dsn = f"sqlite:///{Path('~/.nerve/memu.sqlite').expanduser()}" + default_dsn = f"sqlite:///{paths.memu_sqlite()}" raw_cats = d.get("categories", []) categories = [MemoryCategoryConfig.from_dict(c) for c in raw_cats] return cls( @@ -623,18 +676,18 @@ def from_dict(cls, d: dict) -> MemoryConfig: @dataclass class CronConfig: - jobs_file: Path = field(default_factory=lambda: Path("~/.nerve/cron/jobs.yaml")) - system_file: Path = field(default_factory=lambda: Path("~/.nerve/cron/system.yaml")) + jobs_file: Path = field(default_factory=lambda: paths.cron_dir() / "jobs.yaml") + system_file: Path = field(default_factory=lambda: paths.cron_dir() / "system.yaml") # Directory scanned at startup for drop-in custom gate plugins (.py files # defining CronGate subclasses). See nerve/cron/gate_plugins.py. - gate_plugins_dir: Path = field(default_factory=lambda: Path("~/.nerve/cron/gates")) + gate_plugins_dir: Path = field(default_factory=lambda: paths.cron_dir() / "gates") @classmethod def from_dict(cls, d: dict) -> CronConfig: return cls( - jobs_file=_expand_path(d.get("jobs_file", "~/.nerve/cron/jobs.yaml")) or Path("~/.nerve/cron/jobs.yaml"), - system_file=_expand_path(d.get("system_file", "~/.nerve/cron/system.yaml")) or Path("~/.nerve/cron/system.yaml"), - gate_plugins_dir=_expand_path(d.get("gate_plugins_dir", "~/.nerve/cron/gates")) or Path("~/.nerve/cron/gates"), + jobs_file=_expand_path(d.get("jobs_file")) or paths.cron_dir() / "jobs.yaml", + system_file=_expand_path(d.get("system_file")) or paths.cron_dir() / "system.yaml", + gate_plugins_dir=_expand_path(d.get("gate_plugins_dir")) or paths.cron_dir() / "gates", ) @@ -662,7 +715,7 @@ class BackupConfig: def from_dict(cls, d: dict) -> BackupConfig: return cls( enabled=bool(d.get("enabled", False)), - target_dir=d.get("target_dir", ""), + target_dir=_setting_str(d.get("target_dir")), interval_hours=int(d.get("interval_hours", 24)), retention_count=int(d.get("retention_count", 7)), include_workspace=bool(d.get("include_workspace", True)), @@ -775,7 +828,7 @@ class WorkflowRunsConfig: enabled: bool = True # Root for per-run journal dirs: //{run.json,events.ndjson,result.md} - runs_dir: Path = field(default_factory=lambda: Path("~/.nerve/workflow-runs")) + runs_dir: Path = field(default_factory=lambda: paths.nerve_path("workflow-runs")) # Budget monitor cadence. Spend is re-metered (recorded turn costs + # live in-flight estimate) every interval. poll_interval_seconds: int = 60 @@ -798,8 +851,7 @@ class WorkflowRunsConfig: def from_dict(cls, d: dict) -> WorkflowRunsConfig: return cls( enabled=bool(d.get("enabled", True)), - runs_dir=_expand_path(d.get("runs_dir", "~/.nerve/workflow-runs")) - or Path("~/.nerve/workflow-runs"), + runs_dir=_expand_path(d.get("runs_dir")) or paths.nerve_path("workflow-runs"), poll_interval_seconds=int(d.get("poll_interval_seconds", 60)), warn_fraction=float(d.get("warn_fraction", 0.8)), kill_grace_seconds=int(d.get("kill_grace_seconds", 30)), @@ -953,10 +1005,10 @@ class ProxyConfig: enabled: bool = False port: int = 8317 host: str = "127.0.0.1" - binary_path: Path = field(default_factory=lambda: Path("~/.nerve/bin/cli-proxy-api")) - auth_dir: Path = field(default_factory=lambda: Path("~/.nerve/cli-proxy-auth")) + binary_path: Path = field(default_factory=lambda: paths.nerve_path("bin", "cli-proxy-api")) + auth_dir: Path = field(default_factory=lambda: paths.nerve_path("cli-proxy-auth")) api_key: str = "sk-nerve-local-proxy" # local-only auth between Nerve and the proxy - log_file: Path = field(default_factory=lambda: Path("~/.nerve/proxy.log")) + log_file: Path = field(default_factory=lambda: paths.nerve_path("proxy.log")) @classmethod def from_dict(cls, d: dict) -> ProxyConfig: @@ -964,10 +1016,10 @@ def from_dict(cls, d: dict) -> ProxyConfig: enabled=d.get("enabled", False), port=d.get("port", 8317), host=d.get("host", "127.0.0.1"), - binary_path=_expand_path(d.get("binary_path", "~/.nerve/bin/cli-proxy-api")) or Path("~/.nerve/bin/cli-proxy-api"), - auth_dir=_expand_path(d.get("auth_dir", "~/.nerve/cli-proxy-auth")) or Path("~/.nerve/cli-proxy-auth"), + binary_path=_expand_path(d.get("binary_path")) or paths.nerve_path("bin", "cli-proxy-api"), + auth_dir=_expand_path(d.get("auth_dir")) or paths.nerve_path("cli-proxy-auth"), api_key=d.get("api_key", "sk-nerve-local-proxy"), - log_file=_expand_path(d.get("log_file", "~/.nerve/proxy.log")) or Path("~/.nerve/proxy.log"), + log_file=_expand_path(d.get("log_file")) or paths.nerve_path("proxy.log"), ) @@ -1199,7 +1251,7 @@ class CodexConfig: bin_path: str = "codex" # PATH-resolved codex binary min_version: str = "0.144.1" # inclusive tested protocol range max_version: str = "0.145.0" # exclusive - home_dir: str = "~/.nerve/codex" # isolated CODEX_HOME (auth/config/sessions) + home_dir: str = field(default_factory=lambda: str(paths.nerve_path("codex"))) # isolated CODEX_HOME (auth/config/sessions) model: str = "gpt-5.6-sol" cron_model: str = "" # empty → model auth: str = "chatgpt" # chatgpt | api_key @@ -1253,7 +1305,9 @@ def from_dict(cls, d: dict) -> "CodexConfig": bin_path=str(d.get("bin_path", "codex")), min_version=str(d.get("min_version", "0.144.1")), max_version=str(d.get("max_version", "0.145.0")), - home_dir=str(d.get("home_dir", "~/.nerve/codex")), + home_dir=_setting_str( + d.get("home_dir"), str(paths.nerve_path("codex")) + ), model=str(d.get("model", "gpt-5.6-sol")), cron_model=str(d.get("cron_model") or ""), auth=str(d.get("auth", "chatgpt")).strip().lower(), @@ -1526,7 +1580,7 @@ def from_dict(cls, d: dict) -> "XmemoryConfig": @dataclass class NerveConfig: - workspace: Path = field(default_factory=lambda: Path("~/nerve-workspace")) + workspace: Path = field(default_factory=paths.default_workspace) timezone: str = "America/New_York" deployment: str = "server" # "server" or "docker" quiet_start: str = "02:00" # HH:MM — start of quiet period (local timezone) @@ -1727,7 +1781,7 @@ def from_dict(cls, d: dict) -> NerveConfig: @classmethod def _build_from_dict(cls, d: dict) -> NerveConfig: return cls( - workspace=_expand_path(d.get("workspace", "~/nerve-workspace")) or Path("~/nerve-workspace"), + workspace=_expand_path(d.get("workspace")) or paths.default_workspace(), timezone=d.get("timezone", "America/New_York"), deployment=d.get("deployment", "server"), quiet_start=d.get("quiet_start", "02:00"), @@ -1806,9 +1860,6 @@ def load_mcp_servers(config_dir: Path | None = None) -> list[McpServerConfig]: # daemon start), if it names a directory that still has config files # 5. Current directory (fresh-install fallback) -CONFIG_POINTER_FILE = Path("~/.nerve/config_dir") - - def _has_config_files(directory: Path) -> bool: """True if the directory contains config.yaml or config.local.yaml.""" try: @@ -1822,7 +1873,7 @@ def _has_config_files(directory: Path) -> bool: def read_config_pointer() -> Path | None: """Read the persisted config directory pointer. None if absent/invalid.""" try: - raw = CONFIG_POINTER_FILE.expanduser().read_text(encoding="utf-8").strip() + raw = paths.config_pointer_file().read_text(encoding="utf-8").strip() except (FileNotFoundError, OSError): return None if not raw: @@ -1837,12 +1888,12 @@ def write_config_pointer(config_dir: Path) -> None: Written by `nerve init` (after a successful apply) and on daemon start. Best-effort: failure to write must never break the caller. """ + pointer = paths.config_pointer_file() try: - pointer = CONFIG_POINTER_FILE.expanduser() pointer.parent.mkdir(parents=True, exist_ok=True) pointer.write_text(str(Path(config_dir).expanduser().resolve()), encoding="utf-8") except OSError as e: - logger.warning("Could not write config pointer %s: %s", CONFIG_POINTER_FILE, e) + logger.warning("Could not write config pointer %s: %s", pointer, e) def resolve_config_dir(explicit: str | Path | None = None) -> tuple[Path, str]: diff --git a/nerve/cron/gates.py b/nerve/cron/gates.py index 3ff19c27..f859a9cc 100644 --- a/nerve/cron/gates.py +++ b/nerve/cron/gates.py @@ -41,6 +41,8 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar +from nerve import paths + if TYPE_CHECKING: from nerve.db import Database @@ -402,7 +404,7 @@ async def _gh(*args: str, timeout: float = 30.0) -> str | None: def _state_path(self, job_id: str) -> Path: safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in job_id) - d = Path.home() / ".nerve" / "cache" + d = paths.cache_dir() d.mkdir(parents=True, exist_ok=True) return d / f"pr_activity_{safe}.json" diff --git a/nerve/cron/jobs.py b/nerve/cron/jobs.py index 79201df8..7d7703b2 100644 --- a/nerve/cron/jobs.py +++ b/nerve/cron/jobs.py @@ -161,7 +161,10 @@ def from_dict(cls, d: dict, base_dir: Path | None = None) -> CronJob: id=d["id"], schedule=d["schedule"], prompt=d.get("prompt", ""), - prompt_file=d.get("prompt_file", ""), + # Blank means unset: it is truthy, and it outranks ``prompt`` below, + # so a stray space would beat a perfectly good inline prompt and + # then fail every run trying to read a file named for a space. + prompt_file=str(d.get("prompt_file") or "").strip(), workflow=d.get("workflow"), description=d.get("description", ""), model=d.get("model", ""), diff --git a/nerve/db/__init__.py b/nerve/db/__init__.py index 319c4ce8..4ba7dd6d 100644 --- a/nerve/db/__init__.py +++ b/nerve/db/__init__.py @@ -8,6 +8,7 @@ from pathlib import Path +from nerve import paths from nerve.db.base import SCHEMA_VERSION, Database # Global database instance @@ -32,7 +33,7 @@ async def init_db(db_path: Path | None = None, workspace: Path | None = None) -> """ global _db if db_path is None: - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() _db = Database(db_path, workspace=workspace) await _db.connect() return _db diff --git a/nerve/gateway/routes/external_agents.py b/nerve/gateway/routes/external_agents.py index 85264cf1..0834d8bb 100644 --- a/nerve/gateway/routes/external_agents.py +++ b/nerve/gateway/routes/external_agents.py @@ -161,10 +161,12 @@ def _persist_external_agents_yaml(config) -> None: """ from pathlib import Path + from nerve import paths + # Reuse the same config dir resolution the bootstrap wizard uses. - # The path stamp lives in ``~/.nerve/config_dir`` after the daemon - # starts via ``nerve start``. - config_dir_marker = Path("~/.nerve/config_dir").expanduser() + # The path stamp lives in the config-dir pointer file under the state dir + # after the daemon starts via ``nerve start``. + config_dir_marker = paths.config_pointer_file() if config_dir_marker.exists(): try: config_dir = Path(config_dir_marker.read_text().strip()) diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index b214ad83..427b3624 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -20,6 +20,7 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from nerve import paths from nerve.agent.engine import AgentEngine from nerve.agent.streaming import broadcaster from nerve.config import NerveConfig, get_config @@ -140,7 +141,7 @@ async def lifespan(app: FastAPI): ) # Initialize database - db_path = Path("~/.nerve/nerve.db").expanduser() + db_path = paths.db_path() db = await init_db(db_path, workspace=config.workspace) logger.info("Database initialized at %s", db_path) @@ -312,18 +313,21 @@ async def lifespan(app: FastAPI): pass # One-shot cleanup of retired houseofagents artifacts. Gated on the - # NERVE-MANAGED binary existing (~/.nerve/bin/ is ours): a standalone + # NERVE-MANAGED binary existing (our own bin/ is ours): a standalone # houseofagents install the user runs outside Nerve keeps its # ~/.config/houseofagents/config.toml untouched. When it was ours, the # config.toml holds plaintext API keys Nerve wrote — park it out of the # way; the binary is re-downloadable and just deleted. Best-effort — - # never blocks startup. + # never blocks startup. The two Nerve-owned paths go through the path + # provider so a NERVE_HOME install cleans up its own artifacts instead of + # inspecting a directory it never wrote to; the houseofagents config path + # is that tool's own and stays literal. try: - hoa_binary = Path("~/.nerve/bin/houseofagents").expanduser() + hoa_binary = paths.nerve_path("bin", "houseofagents") if hoa_binary.exists(): hoa_config = Path("~/.config/houseofagents/config.toml").expanduser() if hoa_config.exists() and not hoa_config.is_symlink(): - retired_dir = Path("~/.nerve/houseofagents-retired").expanduser() + retired_dir = paths.nerve_path("houseofagents-retired") retired_dir.mkdir(mode=0o700, parents=True, exist_ok=True) hoa_config.rename(retired_dir / "config.toml.bak") logger.info( @@ -462,7 +466,7 @@ async def _periodic_backup(): from nerve import backup as backup_mod bcfg = config.backup - nerve_dir = Path("~/.nerve").expanduser() + nerve_dir = paths.nerve_home() interval_s = max(1, bcfg.interval_hours) * 3600 target = Path(bcfg.target_dir).expanduser() if bcfg.target_dir else None while True: diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index 4e826d84..9642175b 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -29,6 +29,7 @@ import numpy as np +from nerve import paths from nerve.config import NerveConfig from nerve.observability.langfuse import attributes as lf_attrs @@ -1476,7 +1477,7 @@ async def _initialize_impl(self) -> bool: "client_backend": "sdk", } - resources_dir = Path("~/.nerve/memu-resources").expanduser() + resources_dir = paths.nerve_path("memu-resources") resources_dir.mkdir(parents=True, exist_ok=True) # Fast model for category summaries and date resolution (Haiku). @@ -2406,7 +2407,7 @@ async def memorize_conversation(self, session_id: str, messages: list[dict]) -> op_id = self._metrics.begin_op("memorize_conversation", f"session {session_id}") now = int(time.time()) - conv_dir = Path("~/.nerve/memu-conversations").expanduser() + conv_dir = paths.nerve_path("memu-conversations") conv_path = conv_dir / f"session-{session_id}-{now}.json" def _write_conversation() -> int: diff --git a/nerve/pairing.py b/nerve/pairing.py index 2139e6d1..5e4d34eb 100644 --- a/nerve/pairing.py +++ b/nerve/pairing.py @@ -25,15 +25,16 @@ import time from pathlib import Path +from nerve import paths + logger = logging.getLogger(__name__) -PAIRING_FILE = Path("~/.nerve/telegram_pairing") CODE_TTL_SECONDS = 60 * 60 # 1 hour MAX_ATTEMPTS = 5 def _pairing_path() -> Path: - return PAIRING_FILE.expanduser() + return paths.nerve_path("telegram_pairing") def _read_state() -> dict | None: diff --git a/nerve/paths.py b/nerve/paths.py new file mode 100644 index 00000000..dcf0e06c --- /dev/null +++ b/nerve/paths.py @@ -0,0 +1,171 @@ +"""Central resolver for Nerve's machine-local runtime directories. + +Historically, ``~/.nerve/...`` locations were hardcoded as ``Path("~/.nerve/x")`` +literals scattered across a dozen modules (config dataclass defaults, the CLI, +the bootstrap wizard, cron gates, the DB, pairing, ...). That made the runtime +state directory impossible to relocate and easy to drift out of sync. + +Everything that points at the machine-local state directory now funnels through +this module. Two consequences: + +* The location can be overridden with the ``NERVE_HOME`` environment variable + (defaults to ``~/.nerve``). Handy for tests and for running more than one + instance on a box. +* There is a single, greppable inventory of what lives under the state dir. + +IMPORTANT: this module is for *machine-local runtime state* (databases, tokens, +caches, downloaded binaries, PID/pointer files) — the stuff that is NOT +git-syncable. Git-syncable, shareable configuration lives in the workspace (see +``nerve/workspace.py`` and the ``config/`` subtree), not here. + +Keep this module dependency-free (only ``os``/``pathlib``) so that +``nerve.config`` and everything else can import it without a cycle. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Environment override for the machine-local state directory. +NERVE_HOME_ENV = "NERVE_HOME" + +# Default machine-local state directory name under the user's home. +_DEFAULT_DIRNAME = ".nerve" + + +def nerve_home() -> Path: + """The machine-local runtime state directory (default ``~/.nerve``). + + Override with the ``NERVE_HOME`` env var. Returns an absolute, expanded, + lexically normalized path; the directory is not created here. + + A relative override is anchored to the current working directory at call + time, so callers always get something absolute. This matters because these + paths are handed to subprocesses that run with a different ``cwd`` (git + against the workspace, the proxy binary, the daemon) — a bare relative + path would silently mean a different directory in each of them. + + Normalization applies to absolute overrides too: ``.`` segments, ``..`` + segments, and duplicate or trailing slashes are all collapsed (POSIX's + special leading ``//`` aside), so ``/srv/a/../b`` comes back as + ``/srv/b``. That is deliberate — one directory should mean one set of + state-file paths no matter how the operator spelled it, otherwise two + processes comparing paths as strings disagree about whether they share a + state dir. Two things normalization is *not*: + + * It is not ``realpath``. Symlinks inside the override are left alone, so + the result is stable but not canonical. + * Because ``..`` is collapsed textually rather than by walking the tree, + ``/srv/link/../b`` means ``/srv/b`` even when ``link`` points elsewhere + — the same rule the shell's ``cd -L`` uses, not the kernel's. + + Two more caveats, since a relative ``NERVE_HOME`` is almost always a + mistake: + + * The value is *not* cached. Two calls from different working directories + still disagree, so a relative override is only stable for a process + that never changes directory, and only shared between invocations + launched from the same place. + * ``os.getcwd()`` is canonicalized by the OS, so a relative override + additionally picks up symlink resolution from the cwd prefix. An + absolute override never does. + """ + raw = os.environ.get(NERVE_HOME_ENV, "").strip() + if raw: + return Path(os.path.abspath(Path(raw).expanduser())) + return Path.home() / _DEFAULT_DIRNAME + + +def nerve_path(*parts: str) -> Path: + """A path under the machine-local state directory.""" + return nerve_home().joinpath(*parts) + + +# --- Rendering paths for operator-facing output ------------------------------- # +# +# Use these wherever the program prints a state-dir path back to the operator — +# wizard status lines, doctor findings, commands they are told to run. Writing +# ``~/.nerve`` as a literal in that output was correct before the location was +# overridable and is not now: on an instance that sets NERVE_HOME it names a +# directory Nerve does not use. +# +# Static help text is the exception. ``--help`` describes the command rather +# than this instance, so naming the default location there is accurate. + + +def home_label() -> str: + """The state directory as it should appear in operator-facing output. + + ``~/.nerve`` when that is where the state dir resolves to, the absolute path + when NERVE_HOME moves it. The default case stays short, and the overridden + case says where the files actually are. + + Derived by comparing against the default rather than by testing whether the + env var is set, so it agrees with :func:`nerve_home` even when an override + is spelled some other way — ``NERVE_HOME=~/.nerve`` still prints ``~/.nerve``. + """ + home = nerve_home() + if home == Path.home() / _DEFAULT_DIRNAME: + return f"~/{_DEFAULT_DIRNAME}" + return str(home) + + +def path_label(*parts: str) -> str: + """:func:`nerve_path` rendered for operator-facing output.""" + return str(Path(home_label(), *parts)) + + +# --- Well-known files & subdirectories under the state dir ------------------- # +# +# These accessors are the canonical source of truth for each location. Prefer +# them over re-deriving paths so a future relocation only touches this file. + + +def config_pointer_file() -> Path: + """Pointer file naming the active config directory (``~/.nerve/config_dir``).""" + return nerve_path("config_dir") + + +def db_path() -> Path: + """The main SQLite database (``~/.nerve/nerve.db``).""" + return nerve_path("nerve.db") + + +def pid_file() -> Path: + """The daemon PID file (``~/.nerve/nerve.pid``).""" + return nerve_path("nerve.pid") + + +def log_file() -> Path: + """The daemon log file (``~/.nerve/nerve.log``).""" + return nerve_path("nerve.log") + + +def cache_dir() -> Path: + """Scratch cache directory (gate state, prompt caches, ...).""" + return nerve_path("cache") + + +def memu_sqlite() -> Path: + """The memU memory index database (``~/.nerve/memu.sqlite``).""" + return nerve_path("memu.sqlite") + + +def cron_dir() -> Path: + """Legacy machine-local cron config directory (``~/.nerve/cron``). + + Kept for backward compatibility; new deployments store cron config in the + workspace's ``config/cron`` subtree (see :mod:`nerve.config`). + """ + return nerve_path("cron") + + +def default_workspace() -> Path: + """Default git-syncable workspace root (``~/nerve-workspace``). + + This is *not* under the state dir — the workspace is the portable, shareable + surface. Provided here so the default has a single definition; the effective + workspace is configurable via ``config.workspace``. + """ + return Path.home() / "nerve-workspace" diff --git a/nerve/proxy/service.py b/nerve/proxy/service.py index 1e57aded..e3f3293e 100644 --- a/nerve/proxy/service.py +++ b/nerve/proxy/service.py @@ -21,6 +21,8 @@ import yaml +from nerve import paths + if TYPE_CHECKING: from nerve.config import NerveConfig @@ -57,7 +59,7 @@ class ProxyService: def __init__(self, config: NerveConfig) -> None: self.config = config self._process: asyncio.subprocess.Process | None = None - self._config_path = Path("~/.nerve/cli-proxy-config.yaml").expanduser() + self._config_path = paths.nerve_path("cli-proxy-config.yaml") # ------------------------------------------------------------------ # # Binary management # diff --git a/nerve/sources/telegram.py b/nerve/sources/telegram.py index a3287836..5f703266 100644 --- a/nerve/sources/telegram.py +++ b/nerve/sources/telegram.py @@ -18,6 +18,7 @@ from datetime import datetime, timezone from typing import Any +from nerve import paths from nerve.sources.base import Source from nerve.sources.models import FetchResult, SourceRecord @@ -33,6 +34,20 @@ def __init__(self, config: dict[str, Any]): self._config = config self._client = None + def _session_path(self) -> str: + """Where Telethon keeps the authenticated session, minus ``.session``. + + A blank ``session_path`` means unset. Blank is truthy, so without the + strip the session would land in a file named for a space, *relative to + the cwd* — which is how the interactive ``nerve sync telegram`` and a + later cron run end up looking in two different places, leaving the sync + insisting it is not authenticated with no hint as to why. + """ + configured = str(self._config.get("session_path") or "").strip() + return os.path.expanduser( + configured or str(paths.nerve_path("telegram_sync")) + ) + async def _ensure_client(self) -> bool: """Initialize Telethon client if not already connected.""" if self._client is not None: @@ -50,9 +65,7 @@ async def _ensure_client(self) -> bool: logger.warning("Telegram source: api_id/api_hash not configured") return False - session_path = os.path.expanduser( - self._config.get("session_path", "~/.nerve/telegram_sync") - ) + session_path = self._session_path() # Check if session file exists — Telethon requires interactive auth # on first use, which can't happen in a cron context. diff --git a/tests/conftest.py b/tests/conftest.py index 40405c57..0f4ced7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,54 @@ """Shared test fixtures for Nerve tests.""" import asyncio +import sys import tempfile from pathlib import Path import pytest import pytest_asyncio +import nerve.config # noqa: F401 — imported so its constants can be re-pointed +from nerve import paths from nerve.db import Database +# Machine-local paths that are already materialized by the time a fixture runs, +# keyed by attribute name -> location under the state dir. +# +# ``nerve.paths`` re-reads NERVE_HOME on every call, but a module-level +# ``X = paths.nerve_path(...)`` is evaluated when its module is imported, and +# pytest imports every test module (and everything it pulls in) before the first +# fixture executes. The env override below therefore lands too late for such a +# constant — and by then ``from nerve.config import X`` has copied the stale +# Path into each importer's namespace, so patching the definition alone misses +# them. +# +# Left alone they name the developer's live install: `nerve restart --resume` +# appends to the real resume queue and the daemon-side drainer *unlinks* it. One +# test that forgets one patch is enough to destroy state on the machine running +# the suite, which is why isolation happens here instead of per test. +_IMPORT_TIME_STATE_PATHS = {"RESUME_QUEUE_FILE": ("resume-after-restart",)} + + +def _repoint_import_time_state_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """Rewrite every in-memory copy of the constants above to the temp state dir. + + Sweeps the already-imported ``nerve`` modules rather than listing the known + importers, so a new consumer is covered the day it is added instead of the + day someone notices. A module imported later, inside a test body, picks up + the patched definition in ``nerve.config`` — which is why this file imports + it eagerly. + """ + for attr, parts in _IMPORT_TIME_STATE_PATHS.items(): + target = paths.nerve_path(*parts) + for name, module in list(sys.modules.items()): + if module is None or not (name == "nerve" or name.startswith("nerve.")): + continue + # Type check keeps an unrelated same-named attribute from being + # replaced with a Path behind its owner's back. + if isinstance(getattr(module, attr, None), Path): + monkeypatch.setattr(module, attr, target) + @pytest.fixture(scope="session") def event_loop(): @@ -22,25 +62,20 @@ def event_loop(): def _isolate_nerve_state_files(tmp_path, monkeypatch): """Keep tests away from the real ~/.nerve state files. - The config-dir pointer, wizard init-state, and Telegram pairing files - all live under ~/.nerve on a real install. Tests must never read or - mutate them (running the suite on a live box would otherwise repoint - the daemon's config discovery or leak pairing codes). - """ - import nerve.bootstrap - import nerve.config - import nerve.pairing + The config-dir pointer, wizard init-state, Telegram pairing file, DB, + caches, etc. all live under ~/.nerve on a real install. Tests must never + read or mutate them (running the suite on a live box would otherwise + repoint the daemon's config discovery or leak pairing codes). + Every machine-local path now funnels through ``nerve.paths.nerve_home()``, + which honors the ``NERVE_HOME`` env var, so a single override isolates the + whole state directory instead of patching each constant individually — with + the exception of the paths already frozen at import time, which the second + step rewrites by hand. + """ state_dir = tmp_path / "_nerve_state" - monkeypatch.setattr( - nerve.config, "CONFIG_POINTER_FILE", state_dir / "config_dir", - ) - monkeypatch.setattr( - nerve.bootstrap, "INIT_STATE_FILE", state_dir / "init-state.json", - ) - monkeypatch.setattr( - nerve.pairing, "PAIRING_FILE", state_dir / "telegram_pairing", - ) + monkeypatch.setenv("NERVE_HOME", str(state_dir)) + _repoint_import_time_state_paths(monkeypatch) @pytest_asyncio.fixture diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 8a9674fb..f1f2a553 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -119,10 +119,13 @@ def test_creates_all_files(self, tmp_path: Path) -> None: assert (ws / "SOUL.md").exists() assert (ws / "AGENTS.md").exists() - # Cron jobs file exists - cron_file = Path("~/.nerve/cron/jobs.yaml").expanduser() - # Note: cron file is always written to ~/.nerve, not tmp_path - # We just verify it exists (it may have been created by a previous test/run) + # Cron jobs land under NERVE_HOME (the conftest tmpdir), not the real + # ~/.nerve — until this was fixed, running the suite rewrote the + # developer's own system.yaml. + from nerve import paths + + assert (paths.cron_dir() / "jobs.yaml").exists() + assert (paths.cron_dir() / "system.yaml").exists() def test_worker_mode(self, tmp_path: Path) -> None: """Worker mode should create minimal workspace.""" @@ -319,9 +322,26 @@ def test_dockerfile_content(self, tmp_path: Path) -> None: assert "HEALTHCHECK" in content assert "NERVE_DOCKER=1" in content assert "nodejs" in content + # The GOG install line uses shell ${VAR} syntax — if the template is + # ever turned into a bare f-string it would swallow these. + assert "${GOG_VERSION}" in content + + def test_dockerfile_sets_state_and_workspace_env(self, tmp_path: Path) -> None: + """/root/.nerve must be a deliberate NERVE_HOME, not an artifact of $HOME.""" + from nerve.bootstrap import _DOCKER_NERVE_HOME, _DOCKER_WORKSPACE + + wizard = SetupWizard(tmp_path) + wizard._ensure_docker_files() + + content = (tmp_path / "Dockerfile").read_text() + assert f"ENV NERVE_HOME={_DOCKER_NERVE_HOME}" in content + assert f"ENV NERVE_WORKSPACE={_DOCKER_WORKSPACE}" in content + # The dirs the image pre-creates must be the ones it advertises. + assert f"mkdir -p {_DOCKER_NERVE_HOME} {_DOCKER_WORKSPACE}" in content - def test_compose_content(self, tmp_path: Path) -> None: + def test_compose_content(self, tmp_path: Path, monkeypatch) -> None: """docker-compose.yml should have correct service definition.""" + monkeypatch.delenv("NERVE_HOME", raising=False) wizard = SetupWizard(tmp_path) wizard._ensure_docker_files() @@ -335,6 +355,61 @@ def test_compose_content(self, tmp_path: Path) -> None: assert ".:/nerve" in volumes assert "~/.nerve:/root/.nerve" in volumes + def test_compose_host_state_dir_follows_nerve_home( + self, tmp_path: Path, monkeypatch + ) -> None: + """Deriving only the container side gives host and container two DBs. + + The operator relocated the state dir; if compose keeps mounting the + default ~/.nerve, `nerve sessions` on the host and the daemon in the + container read different databases with no sign anything is wrong. + """ + monkeypatch.setenv("NERVE_HOME", "/opt/nerve-state") + wizard = SetupWizard(tmp_path) + wizard._ensure_docker_files() + + compose = yaml.safe_load((tmp_path / "docker-compose.yml").read_text()) + volumes = compose["services"]["nerve"]["volumes"] + assert "/opt/nerve-state:/root/.nerve" in volumes + assert "/opt/nerve-state/claude:/root/.claude" in volumes + assert not any(v.startswith("~/.nerve") for v in volumes) + + def test_compose_mounts_match_dockerfile_env(self, tmp_path: Path) -> None: + """A mount pointing somewhere the image doesn't use is a silent no-op.""" + from nerve.bootstrap import _DOCKER_NERVE_HOME, _DOCKER_WORKSPACE + + wizard = SetupWizard(tmp_path) + wizard._ensure_docker_files() + + compose = yaml.safe_load((tmp_path / "docker-compose.yml").read_text()) + targets = {v.split(":", 1)[1] for v in compose["services"]["nerve"]["volumes"]} + assert _DOCKER_NERVE_HOME in targets + assert _DOCKER_WORKSPACE in targets + + def test_entrypoint_clears_pid_under_nerve_home(self, tmp_path: Path) -> None: + """A hard-coded ~/.nerve here would miss the PID file if NERVE_HOME moves.""" + wizard = SetupWizard(tmp_path) + wizard._ensure_docker_files() + + content = (tmp_path / "docker-entrypoint.sh").read_text() + assert 'rm -f "${NERVE_HOME:-$HOME/.nerve}/nerve.pid"' in content + + def test_agent_docs_locate_config_where_it_is_actually_written(self) -> None: + """This text is appended to TOOLS.md, so the agent reads it as fact. + + In the container the entrypoint `cd /nerve`s before `nerve init`, and + resolve_config_dir() falls through to the cwd — so config.yaml and + config.local.yaml land in /nerve, not under NERVE_HOME. The doc used + to claim the opposite, which sends the agent to edit a file nothing + reads (and on a different bind mount than the one it meant). + """ + from nerve.bootstrap import _DOCKER_NERVE_HOME, _DOCKER_TOOLS_SECTION + + assert f"Config files live in `/nerve/`" in _DOCKER_TOOLS_SECTION + assert f"`{_DOCKER_NERVE_HOME}/config.local.yaml`" not in _DOCKER_TOOLS_SECTION + # Cron still lives under the machine-local state dir on this branch. + assert f"{_DOCKER_NERVE_HOME}/cron/" in _DOCKER_TOOLS_SECTION + def test_entrypoint_executable(self, tmp_path: Path) -> None: """docker-entrypoint.sh should be executable.""" wizard = SetupWizard(tmp_path) @@ -466,8 +541,11 @@ def test_compose_valid_yaml(self) -> None: assert "services" in parsed assert "nerve" in parsed["services"] - def test_compose_bind_mounts(self) -> None: + def test_compose_bind_mounts(self, monkeypatch) -> None: """Compose should use host bind-mounts, not named volumes.""" + # The conftest fixture points NERVE_HOME at a tmpdir; clear it so this + # exercises the default an ordinary operator gets. + monkeypatch.delenv("NERVE_HOME", raising=False) # Mock all optional dirs as existing so they appear in output with patch("nerve.bootstrap.os.path.isdir", return_value=True), \ patch("nerve.bootstrap.os.path.expanduser", side_effect=lambda p: p): @@ -484,8 +562,9 @@ def test_compose_bind_mounts(self) -> None: # No named volumes section assert "volumes" not in parsed or parsed.get("volumes") is None - def test_compose_skips_missing_auth_dirs(self) -> None: + def test_compose_skips_missing_auth_dirs(self, monkeypatch) -> None: """Optional auth mounts should be excluded when host dirs don't exist.""" + monkeypatch.delenv("NERVE_HOME", raising=False) with patch("nerve.bootstrap.os.path.isdir", return_value=False), \ patch("nerve.bootstrap.os.path.expanduser", side_effect=lambda p: p): content = _build_docker_compose(workspace_path="~/ws") @@ -499,8 +578,9 @@ def test_compose_skips_missing_auth_dirs(self) -> None: assert "~/.config/gh:/root/.config/gh" not in volumes assert "~/.config/gog:/root/.config/gog" not in volumes - def test_compose_extra_mounts(self) -> None: + def test_compose_extra_mounts(self, monkeypatch) -> None: """Extra mounts should appear in the volumes list.""" + monkeypatch.delenv("NERVE_HOME", raising=False) with patch("nerve.bootstrap.os.path.isdir", return_value=False), \ patch("nerve.bootstrap.os.path.expanduser", side_effect=lambda p: p): content = _build_docker_compose( @@ -851,11 +931,10 @@ def test_clear(self) -> None: assert _load_init_state() is None def test_state_file_permissions(self) -> None: - import nerve.bootstrap as bootstrap_mod - from nerve.bootstrap import _save_init_state + from nerve.bootstrap import _init_state_file, _save_init_state _save_init_state(SetupChoices(), {"mode"}) - path = bootstrap_mod.INIT_STATE_FILE.expanduser() + path = _init_state_file() mode = stat.S_IMODE(os.stat(path).st_mode) assert mode == 0o600 diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index cbf0ff21..e7061211 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -1,5 +1,5 @@ """Tests for config-dir resolution, the pointer file, unknown-key -validation, and config write-back helpers.""" +validation, blank path settings, and config write-back helpers.""" from __future__ import annotations @@ -10,8 +10,17 @@ import pytest import yaml +from nerve import paths from nerve.config import ( + BackupConfig, + CodexConfig, + CodexOriginConfig, + CronConfig, + NerveConfig, + ProxyConfig, + SSLConfig, TelegramConfig, + WorkflowRunsConfig, append_telegram_allowed_user, load_config, read_config_pointer, @@ -322,3 +331,139 @@ def test_models_key_recognized_by_validator(self): # Guard: agent.models must not trip unknown-key warnings. merged = {"agent": {"models": ["claude-opus-5"]}} assert validate_config_keys(merged) == [] + + +class TestBlankPathSettingsMeanUnset: + """A path setting left blank must fall back to its default, not to ".". + + ``Path("")`` is ``Path(".")`` and truthy, so a key written as ``runs_dir:`` + or ``cert: ""`` would otherwise sail straight past the ``or `` + fallback and resolve to the working directory the daemon was started in — + silently, since that directory usually exists and is writable. One test per + key, because each one goes wrong in its own way: writing state where nobody + will look for it, importing whatever ``.py`` files happen to be lying + around, or serving TLS with no certificate. + """ + + def test_gateway_ssl_blank_cert_and_key_disable_tls(self): + ssl = SSLConfig.from_dict({"cert": "", "key": ""}) + assert (ssl.cert, ssl.key) == (None, None) + assert ssl.enabled is False + + def test_gateway_ssl_blank_cert_alone_disables_tls(self): + """``enabled`` means "both files are set" — half-configured is off.""" + ssl = SSLConfig.from_dict({"cert": "", "key": "/etc/ssl/nerve.key"}) + assert ssl.enabled is False + + def test_workflows_runs_dir(self): + """Run journals belong under the state dir, not next to the daemon.""" + cfg = WorkflowRunsConfig.from_dict({"runs_dir": ""}) + assert cfg.runs_dir == paths.nerve_path("workflow-runs") + + def test_proxy_binary_path(self): + cfg = ProxyConfig.from_dict({"binary_path": ""}) + assert cfg.binary_path == paths.nerve_path("bin", "cli-proxy-api") + + def test_proxy_auth_dir(self): + """Proxy OAuth material must not be dropped into the cwd.""" + cfg = ProxyConfig.from_dict({"auth_dir": ""}) + assert cfg.auth_dir == paths.nerve_path("cli-proxy-auth") + + def test_proxy_log_file(self): + cfg = ProxyConfig.from_dict({"log_file": ""}) + assert cfg.log_file == paths.nerve_path("proxy.log") + + def test_cron_gate_plugins_dir(self): + """This one executes what it finds — every ``*.py`` in the directory.""" + cfg = CronConfig.from_dict({"gate_plugins_dir": ""}) + assert cfg.gate_plugins_dir == paths.cron_dir() / "gates" + + def test_cron_jobs_file(self): + cfg = CronConfig.from_dict({"jobs_file": ""}) + assert cfg.jobs_file == paths.cron_dir() / "jobs.yaml" + + def test_cron_system_file(self): + cfg = CronConfig.from_dict({"system_file": ""}) + assert cfg.system_file == paths.cron_dir() / "system.yaml" + + def test_workspace(self): + cfg = NerveConfig.from_dict({"workspace": ""}) + assert cfg.workspace == paths.default_workspace() + + @pytest.mark.parametrize("blank", [" ", "\t", "\n", " \t "]) + def test_whitespace_only_counts_as_blank(self, blank): + """A stray space after the colon is the likeliest way to write this.""" + cfg = CronConfig.from_dict({"gate_plugins_dir": blank}) + assert cfg.gate_plugins_dir == paths.cron_dir() / "gates" + + def test_an_env_var_that_expands_to_nothing_is_blank_too(self, monkeypatch): + """``NERVE_TEST_RUNS_DIR=`` in a unit file or compose env block.""" + monkeypatch.setenv("NERVE_TEST_RUNS_DIR", "") + cfg = WorkflowRunsConfig.from_dict({"runs_dir": "$NERVE_TEST_RUNS_DIR"}) + assert cfg.runs_dir == paths.nerve_path("workflow-runs") + + def test_a_configured_path_is_still_honored(self): + cfg = CronConfig.from_dict({"gate_plugins_dir": "/opt/nerve/gates"}) + assert cfg.gate_plugins_dir == Path("/opt/nerve/gates") + + def test_tilde_still_expands(self): + cfg = WorkflowRunsConfig.from_dict({"runs_dir": "~/runs"}) + assert cfg.runs_dir == Path.home() / "runs" + + def test_surrounding_whitespace_is_trimmed_not_baked_in(self): + cfg = WorkflowRunsConfig.from_dict({"runs_dir": " ~/runs "}) + assert cfg.runs_dir == Path.home() / "runs" + + def test_a_set_env_var_still_expands(self, monkeypatch, tmp_path): + monkeypatch.setenv("NERVE_TEST_RUNS_DIR", str(tmp_path / "runs")) + cfg = WorkflowRunsConfig.from_dict({"runs_dir": "$NERVE_TEST_RUNS_DIR"}) + assert cfg.runs_dir == tmp_path / "runs" + + +class TestBlankStringPathSettingsMeanUnset: + """The same rule for the path settings that stay ``str``. + + These fields are not ``Path``-typed, so they never reach + ``_expand_path`` — something downstream re-expands them instead. That + makes the rule easy to lose: ``or `` catches ``""`` and a + missing key but not ``" "``, which is truthy and survives all the way + to ``Path(" ")`` — a directory named two spaces, relative to wherever + the daemon happened to start. A stray space after the colon is the + likeliest way to write any of these. + """ + + @pytest.mark.parametrize("blank", ["", " ", "\t", "\n", " \t "]) + def test_codex_origin_sessions_path(self, blank): + cfg = CodexOriginConfig.from_dict({"path": blank}) + assert cfg.path == "~/.codex/sessions" + + @pytest.mark.parametrize("blank", ["", " ", "\t", "\n", " \t "]) + def test_codex_origin_archive_path(self, blank): + """Rollouts get *moved* here — a wrong value relocates real data.""" + cfg = CodexOriginConfig.from_dict({"archive_path": blank}) + assert cfg.archive_path == "~/.codex/archived_sessions" + + @pytest.mark.parametrize("blank", ["", " ", "\t", "\n", " \t "]) + def test_codex_home_dir(self, blank): + cfg = CodexConfig.from_dict({"home_dir": blank}) + assert cfg.home_dir == str(paths.nerve_path("codex")) + + @pytest.mark.parametrize("blank", [" ", "\t", "\n", " \t "]) + def test_backup_target_dir_stays_unset(self, blank): + """Blank means "no destination configured", and must keep meaning it. + + ``target_dir`` has no default to fall back to — the empty string *is* + the unset value, and callers gate on it. Whitespace would read as a + configured destination and send bundles to a directory named for a + space. + """ + cfg = BackupConfig.from_dict({"target_dir": blank}) + assert cfg.target_dir == "" + + def test_a_configured_string_path_is_still_honored(self): + cfg = CodexOriginConfig.from_dict({"path": "/srv/codex/sessions"}) + assert cfg.path == "/srv/codex/sessions" + + def test_surrounding_whitespace_is_trimmed_not_baked_in(self): + cfg = CodexOriginConfig.from_dict({"path": " /srv/codex/sessions "}) + assert cfg.path == "/srv/codex/sessions" diff --git a/tests/test_cron.py b/tests/test_cron.py index d1614bdc..c2fea377 100644 --- a/tests/test_cron.py +++ b/tests/test_cron.py @@ -997,6 +997,25 @@ def test_prompt_file_wins_over_inline(self, tmp_path): job = CronJob(id="x", schedule="1h", prompt="inline", prompt_file=str(pf)) assert job.resolve_prompt() == "file wins" + @pytest.mark.parametrize("blank", [" ", "\t", "\n", " \t "]) + def test_blank_prompt_file_is_unset_not_a_file_named_space(self, blank): + """``prompt_file: `` with a stray space must not beat the inline prompt. + + Blank is truthy, so without the strip it wins the precedence above and + the job then tries to read a file whose name is a space — failing every + run, while the perfectly good inline prompt sits there unused. + """ + job = CronJob.from_dict( + {"id": "x", "schedule": "1h", "prompt": "inline", "prompt_file": blank} + ) + assert job.prompt_file == "" + assert job.resolve_prompt() == "inline" + + def test_blank_prompt_file_with_no_prompt_fails_at_load(self): + """And with nothing to fall back on it fails loudly, naming the job.""" + with pytest.raises(ValueError): + CronJob.from_dict({"id": "x", "schedule": "1h", "prompt_file": " "}) + def test_prompt_file_read_fresh_each_run(self, tmp_path): pf = tmp_path / "prompt.md" pf.write_text("v1", encoding="utf-8") diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 00000000..5fdc04fb --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,375 @@ +"""Tests for nerve.paths — the central machine-local directory resolver.""" + +import ast +import os +import re +from pathlib import Path + +from nerve import paths + + +class TestNerveHome: + def test_default_is_dot_nerve_under_home(self, monkeypatch): + # The autouse conftest fixture sets NERVE_HOME; clear it to see the default. + monkeypatch.delenv(paths.NERVE_HOME_ENV, raising=False) + assert paths.nerve_home() == Path.home() / ".nerve" + + def test_env_override(self, monkeypatch, tmp_path): + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "state")) + assert paths.nerve_home() == tmp_path / "state" + + def test_env_override_expands_user(self, monkeypatch): + monkeypatch.setenv(paths.NERVE_HOME_ENV, "~/custom-nerve") + assert paths.nerve_home() == Path.home() / "custom-nerve" + + def test_blank_env_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv(paths.NERVE_HOME_ENV, " ") + assert paths.nerve_home() == Path.home() / ".nerve" + + def test_override_is_read_lazily(self, monkeypatch, tmp_path): + """Each call re-reads the env var — no value frozen at import.""" + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "a")) + assert paths.nerve_home() == tmp_path / "a" + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "b")) + assert paths.nerve_home() == tmp_path / "b" + + def test_relative_override_is_made_absolute(self, monkeypatch, tmp_path): + """A relative override must not be handed to subprocesses verbatim.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(paths.NERVE_HOME_ENV, "relative/state") + home = paths.nerve_home() + assert home.is_absolute() + assert home == Path(os.getcwd()) / "relative" / "state" + + def test_override_is_normalized(self, monkeypatch, tmp_path): + monkeypatch.setenv(paths.NERVE_HOME_ENV, f"{tmp_path}/a/../b/./c") + assert paths.nerve_home() == tmp_path / "b" / "c" + + def test_absolute_override_is_normalized_too(self, monkeypatch, tmp_path): + """One directory, one spelling — however the operator wrote it. + + Two processes that compare state paths as strings must agree on + whether they share a state dir, so trailing/duplicate slashes and + ``.``/``..`` segments are collapsed rather than carried around. + """ + for spelling in (f"{tmp_path}/state/", f"{tmp_path}//state", f"{tmp_path}/./state"): + monkeypatch.setenv(paths.NERVE_HOME_ENV, spelling) + assert paths.nerve_home() == tmp_path / "state", spelling + + def test_dotdot_is_collapsed_lexically_not_through_symlinks(self, monkeypatch, tmp_path): + """``..`` is resolved textually, and symlinks are left alone. + + Documented deliberately: normalization is ``abspath``, not + ``realpath``, so callers must not expect a canonical path back. + """ + (tmp_path / "elsewhere").mkdir() + (tmp_path / "link").symlink_to(tmp_path / "elsewhere") + monkeypatch.setenv(paths.NERVE_HOME_ENV, f"{tmp_path}/link/../state") + assert paths.nerve_home() == tmp_path / "state" + + # ...and a symlink that is not followed by ``..`` survives untouched. + monkeypatch.setenv(paths.NERVE_HOME_ENV, f"{tmp_path}/link/state") + assert paths.nerve_home() == tmp_path / "link" / "state" + + def test_accessors_are_absolute_under_relative_override(self, monkeypatch, tmp_path): + """Every derived path inherits the absolute base, not just nerve_home().""" + # os.getcwd() is canonicalized, so a relative override is anchored to + # the real path of the cwd — compare against that, not tmp_path. + monkeypatch.chdir(tmp_path) + real = Path(os.getcwd()) + monkeypatch.setenv(paths.NERVE_HOME_ENV, "state") + for path in (paths.db_path(), paths.pid_file(), paths.log_file(), + paths.cron_dir(), paths.config_pointer_file()): + assert path.is_absolute(), path + assert path.parent == real / "state", path + + +class TestAccessors: + def test_accessors_live_under_nerve_home(self, monkeypatch, tmp_path): + home = tmp_path / "state" + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(home)) + assert paths.nerve_path("x", "y") == home / "x" / "y" + assert paths.config_pointer_file() == home / "config_dir" + assert paths.db_path() == home / "nerve.db" + assert paths.pid_file() == home / "nerve.pid" + assert paths.log_file() == home / "nerve.log" + assert paths.cache_dir() == home / "cache" + assert paths.memu_sqlite() == home / "memu.sqlite" + assert paths.cron_dir() == home / "cron" + + def test_default_workspace_is_not_under_state_dir(self, monkeypatch, tmp_path): + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "state")) + ws = paths.default_workspace() + assert ws == Path.home() / "nerve-workspace" + assert (tmp_path / "state") not in ws.parents + + +class TestLabels: + def test_default_is_rendered_as_the_short_spelling(self, monkeypatch): + monkeypatch.delenv(paths.NERVE_HOME_ENV, raising=False) + assert paths.home_label() == "~/.nerve" + assert paths.path_label("bin", "x") == "~/.nerve/bin/x" + + def test_an_override_is_rendered_absolute(self, monkeypatch, tmp_path): + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "state")) + assert paths.home_label() == str(tmp_path / "state") + assert paths.path_label("cron") == str(tmp_path / "state" / "cron") + + def test_an_override_naming_the_default_is_still_the_short_spelling(self, monkeypatch): + """The label tracks where the state dir lands, not how it was spelled. + + ``NERVE_HOME`` set to the default location is the same directory, so + printing an absolute path there would be noise. + """ + monkeypatch.setenv(paths.NERVE_HOME_ENV, "~/.nerve") + assert paths.home_label() == "~/.nerve" + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(Path.home() / ".nerve")) + assert paths.home_label() == "~/.nerve" + + def test_labels_name_the_directory_the_accessors_use(self, monkeypatch, tmp_path): + """A label that disagrees with the accessor is worse than no label.""" + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "state")) + assert paths.home_label() == str(paths.nerve_home()) + for parts in (("cron",), ("bin", "cli-proxy-api"), ("nerve.db",)): + assert paths.path_label(*parts) == str(paths.nerve_path(*parts)) + + +class TestNothingBypassesTheProvider: + """A provider only helps if nothing builds the same paths without it. + + ``NERVE_HOME`` is supposed to move every machine-local file at once. Code + that names a literal home instead keeps writing to the default, so one + instance reads the state another wrote — and nothing fails loudly, because + both paths exist and both are writable. Each such site has to be found by + reading the diff, which is exactly how the last few arrived. + + Both spellings of the mistake are equally invisible: ``Path("~/.nerve/x")`` + and ``Path.home() / ".nerve" / "x"`` ignore the override identically, so + the guard has to know about both. + """ + + # Deliberately narrow: these match *constructing* a path from a literal + # home, not naming one. Comments and docstrings that mention the default + # location are normal and there are dozens of them — every pattern here + # needs a call and a quoted path component, which prose never has. + _BUILDS_FROM_LITERAL_HOME = re.compile( + r""" + (?: Path | expanduser ) \( \s* ["'] ~/\.nerve # Path("~/.nerve/x") + | \.home\(\) \s* (?: / | \.joinpath\( ) \s* ["']\.nerve # Path.home() / ".nerve" + """, + re.VERBOSE, + ) + + def test_no_module_builds_a_machine_local_path_from_a_literal_home(self): + package_root = Path(paths.__file__).parent + offenders: list[str] = [] + for source in sorted(package_root.rglob("*.py")): + # paths.py is the provider itself; its module docstring names the + # very pattern it exists to replace. + if source == Path(paths.__file__): + continue + for lineno, line in enumerate( + source.read_text(encoding="utf-8").splitlines(), start=1, + ): + if self._BUILDS_FROM_LITERAL_HOME.search(line): + rel = source.relative_to(package_root.parent) + offenders.append(f"{rel}:{lineno}: {line.strip()}") + + assert not offenders, ( + "Build machine-local paths through nerve.paths so NERVE_HOME is " + "honored (nerve_path/db_path/cron_dir/...), instead of naming a " + "literal home:\n" + "\n".join(offenders) + ) + + def test_the_guard_catches_both_spellings(self): + """The guard is the only thing standing between us and a silent bypass. + + A typo in the regex would make it pass forever, so pin what it does and + does not match: constructions are offences, prose about the default + location is not. + """ + offences = [ + 'CONFIG = Path("~/.nerve/config_dir")', + "cache = Path('~/.nerve') / 'cache'", + 'db = os.path.expanduser("~/.nerve/nerve.db")', + 'state = Path.home() / ".nerve" / "cache"', + "state = Path.home()/'.nerve'", + 'state = Path.home().joinpath(".nerve", "cache")', + ] + allowed = [ + "# Defaults to ~/.nerve/nerve.db unless NERVE_HOME says otherwise.", + '"""The daemon PID file (``~/.nerve/nerve.pid``)."""', + 'claude_dir = Path.home() / ".claude"', + 'ws = Path.home() / "nerve-workspace"', + 'db = paths.nerve_path("nerve.db")', + ] + for line in offences: + assert self._BUILDS_FROM_LITERAL_HOME.search(line), line + for line in allowed: + assert not self._BUILDS_FROM_LITERAL_HOME.search(line), line + + +class TestOutputNamesTheDirectoryItUses: + """The guard above allows a line that only *names* the default. Output is + not prose, though, and the allowance covers it too. + + A status line, a doctor finding, a command the operator is told to paste — + each is a claim about this instance, and on one that sets ``NERVE_HOME`` + each is false. The wizard reports that it set up ``~/.nerve`` and creates + something else; the fallback instruction names a binary that is not there. + Both were correct before the location became overridable. + + So a ``~/.nerve`` literal reaching a call is an offence: strings get printed, + while docstrings and comments are not call arguments and never arrive here. + Static ``help=`` text is the exception — ``--help`` describes the command, + not the running instance, so the default location is the right thing to name. + """ + + @staticmethod + def _offenders(source: str, filename: str = "") -> list[str]: + found: set[tuple[int, str]] = set() + for node in ast.walk(ast.parse(source, filename=filename)): + if not isinstance(node, ast.Call): + continue + passed = [(None, a) for a in node.args] + passed += [(kw.arg, kw.value) for kw in node.keywords] + for kwarg, value in passed: + if kwarg == "help": + continue + for sub in ast.walk(value): + if ( + isinstance(sub, ast.Constant) + and isinstance(sub.value, str) + and "~/.nerve" in sub.value + ): + found.add((sub.lineno, ast.unparse(node.func))) + return [f"{lineno}: {func}(...)" for lineno, func in sorted(found)] + + def test_no_output_string_names_a_literal_home(self): + package_root = Path(paths.__file__).parent + offenders: list[str] = [] + for source in sorted(package_root.rglob("*.py")): + # paths.py renders the labels; its own docstrings name the default. + if source == Path(paths.__file__): + continue + rel = source.relative_to(package_root.parent) + offenders += [ + f"{rel}:{o}" + for o in self._offenders(source.read_text(encoding="utf-8"), str(source)) + ] + + assert not offenders, ( + "These hand a literal ~/.nerve to a call, so the text is wrong on an " + "instance that moved the state dir. Render it with paths.home_label() " + "or paths.path_label():\n" + "\n".join(offenders) + ) + + def test_the_guard_reads_output_and_not_prose(self): + """Pin the boundary, since a guard that matches nothing passes forever.""" + offences = [ + 'click.echo(" Setting up ~/.nerve/...", nl=False)', + 'click.secho(f"{n} jobs found in ~/.nerve/cron", dim=True)', + 'lines.append("[--] backups protect ~/.nerve against disk loss")', + 'logger.warning("legacy ~/.nerve/cron is ignored when locked")', + ] + allowed = [ + "# Nerve keeps machine-local state in ~/.nerve by default.", + '"""The daemon PID file (``~/.nerve/nerve.pid``)."""', + 'click.option("--state-only", help="Back up ~/.nerve state only")', + 'click.echo(f" Setting up {paths.home_label()}/...", nl=False)', + 'click.secho(f" --config {paths.path_label(\'cli-proxy-config.yaml\')}")', + 'db = paths.nerve_path("nerve.db")', + ] + for snippet in offences: + assert self._offenders(snippet), snippet + for snippet in allowed: + assert not self._offenders(snippet), snippet + + def test_the_printed_proxy_paths_are_the_ones_the_proxy_uses(self, monkeypatch, tmp_path): + """The guard forces the instruction through the labels; this is why that + is enough. + + ``_step_proxy_setup`` prints a command for the operator to paste when + they decline to authenticate now. It has to name the binary the proxy + downloads and the config file it reads, or the command fails. + """ + from nerve.config import NerveConfig, ProxyConfig + from nerve.proxy.service import ProxyService + + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "state")) + service = ProxyService(NerveConfig(proxy=ProxyConfig(enabled=True))) + assert paths.path_label("cli-proxy-config.yaml") == str(service._config_path) + assert paths.path_label("bin", "cli-proxy-api") == str( + service.config.proxy.binary_path, + ) + + +class TestImportTimeConstantsRespectTheOverride: + """Lazy accessors are not enough when a module freezes one into a constant. + + ``nerve_home()`` re-reads ``NERVE_HOME`` on every call, but a module-level + ``X = paths.nerve_path(...)`` is evaluated once, when the module is + imported. Under pytest that happens during collection — before any fixture + runs — so such a constant names the *real* ``~/.nerve`` for the whole + session, and ``from nerve.config import X`` copies that stale Path into + every importer's namespace. A test that forgets to patch it then reads and + writes the developer's live install; the resume drainer even unlinks its + file. The autouse fixture in conftest re-points every copy, and these tests + keep it honest. + """ + + def test_known_constants_point_into_the_isolated_state_dir(self): + from nerve import cli, config + from nerve.agent import engine + + expected = paths.nerve_path("resume-after-restart") + for module in (config, cli, engine): + assert module.RESUME_QUEUE_FILE == expected, module.__name__ + + def test_doctor_config_source_label_follows_the_override(self, monkeypatch, tmp_path): + """A path baked into a *string* is just as frozen as one in a Path. + + The doctor tells the reader which pointer file the config came from; + resolved at import time it can name a file this process never read. + """ + from nerve import cli + + monkeypatch.setenv(paths.NERVE_HOME_ENV, str(tmp_path / "elsewhere")) + label = cli._config_source_label("pointer") + assert str(tmp_path / "elsewhere" / "config_dir") in label + + def test_no_unhandled_module_level_state_path_exists(self): + """A new import-time constant has to be added to the conftest fixture. + + Without this the next one is isolated nowhere and nothing says so: the + suite keeps passing while quietly writing outside the temp state dir. + """ + # Read the inventory the fixture actually uses, so adding an entry + # there is all it takes — no second list to forget. + from tests.conftest import _IMPORT_TIME_STATE_PATHS + + package_root = Path(paths.__file__).parent + found: list[str] = [] + for source in sorted(package_root.rglob("*.py")): + if source == Path(paths.__file__): + continue # the provider's own accessors are functions, not constants + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in tree.body: # module scope only + targets = ( + node.targets if isinstance(node, ast.Assign) + else [node.target] if isinstance(node, ast.AnnAssign) and node.value + else [] + ) + if not targets or "paths." not in ast.unparse(node.value): + continue + for target in targets: + if isinstance(target, ast.Name) and target.id not in _IMPORT_TIME_STATE_PATHS: + rel = source.relative_to(package_root.parent) + found.append(f"{rel}:{node.lineno}: {target.id}") + + assert not found, ( + "These module-level constants materialize a machine-local path at " + "import time, which is before the test fixtures can move the state " + "dir. Add each to _IMPORT_TIME_STATE_PATHS in tests/conftest.py (or " + "make it a function so it resolves lazily):\n" + "\n".join(found) + ) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 02f891b3..318c2339 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -12,6 +12,7 @@ import pytest_asyncio import yaml +from nerve import paths from nerve.config import NerveConfig, ProxyConfig, load_config @@ -29,9 +30,11 @@ def test_defaults(self) -> None: assert pc.port == 8317 assert pc.host == "127.0.0.1" assert pc.api_key == "sk-nerve-local-proxy" - assert pc.binary_path == Path("~/.nerve/bin/cli-proxy-api") - assert pc.auth_dir == Path("~/.nerve/cli-proxy-auth") - assert pc.log_file == Path("~/.nerve/proxy.log") + # Defaults now resolve through nerve.paths (honoring NERVE_HOME) and are + # expanded eagerly — matching what from_dict already produced at runtime. + assert pc.binary_path == paths.nerve_path("bin", "cli-proxy-api") + assert pc.auth_dir == paths.nerve_path("cli-proxy-auth") + assert pc.log_file == paths.nerve_path("proxy.log") def test_from_dict_empty(self) -> None: pc = ProxyConfig.from_dict({}) diff --git a/tests/test_resume_after_restart.py b/tests/test_resume_after_restart.py index 32f0915f..2459c0e2 100644 --- a/tests/test_resume_after_restart.py +++ b/tests/test_resume_after_restart.py @@ -3,13 +3,15 @@ from __future__ import annotations +from contextlib import ExitStack +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import click import pytest -from nerve import cli +from nerve import cli, paths from nerve.agent.engine import AgentEngine, _RESUME_AFTER_RESTART_PROMPT from nerve.agent.sessions import SessionStatus @@ -128,15 +130,27 @@ async def _run(**kwargs): # CLI side: `nerve restart --resume` writes the queue for all modes # # --------------------------------------------------------------------------- # -def _invoke_restart(tmp_path, resume_ids): - qf = tmp_path / "resume-after-restart" - logf = tmp_path / "nerve.log" - with patch("nerve.cli._is_docker_mode", return_value=False), \ - patch("nerve.cli._is_systemd_managed", return_value=False), \ - patch("nerve.cli._get_daemon_status", return_value=(False, None)), \ - patch("nerve.cli.subprocess.Popen", MagicMock()), \ - patch("nerve.cli.LOG_FILE", logf), \ - patch("nerve.cli.RESUME_QUEUE_FILE", qf): +def _invoke_restart(tmp_path, resume_ids, *, redirect_queue=True): + """Drive ``nerve restart`` with the daemon and its side effects stubbed out. + + Returns the queue file the run should have written to. With + ``redirect_queue=False`` the CLI's own module-level constant is left in + place, so the write goes wherever the test-suite isolation put it. + """ + with ExitStack() as stack: + for cm in ( + patch("nerve.cli._is_docker_mode", return_value=False), + patch("nerve.cli._is_systemd_managed", return_value=False), + patch("nerve.cli._get_daemon_status", return_value=(False, None)), + patch("nerve.cli.subprocess.Popen", MagicMock()), + patch("nerve.paths.log_file", return_value=tmp_path / "nerve.log"), + ): + stack.enter_context(cm) + if redirect_queue: + qf = tmp_path / "resume-after-restart" + stack.enter_context(patch("nerve.cli.RESUME_QUEUE_FILE", qf)) + else: + qf = cli.RESUME_QUEUE_FILE ctx = click.Context(cli.restart) ctx.obj = { "config": SimpleNamespace(deployment="server"), @@ -162,3 +176,35 @@ def test_restart_resume_appends(tmp_path): def test_restart_without_resume_writes_nothing(tmp_path): qf = _invoke_restart(tmp_path, ()) assert not qf.exists() + + +# --------------------------------------------------------------------------- # +# The queue file must never be the developer's real one # +# --------------------------------------------------------------------------- # +# +# RESUME_QUEUE_FILE is a module-level Path, so it is resolved when nerve.config +# is imported — which, under pytest, is during collection, before any fixture +# has moved the state dir. nerve.cli and nerve.agent.engine each hold their own +# imported copy of that value. The two tests below run *without* patching it, so +# they fail unless the suite's isolation redirected every copy: the CLI appends +# to the queue and the drainer deletes it, which on a live box means corrupting +# and then destroying the real ~/.nerve/resume-after-restart. + +def test_cli_writes_the_queue_inside_the_isolated_state_dir(tmp_path): + qf = _invoke_restart(tmp_path, ("S1",), redirect_queue=False) + assert qf == paths.nerve_path("resume-after-restart") + assert paths.nerve_home() in qf.parents + assert (Path.home() / ".nerve") not in qf.parents + assert qf.read_text() == "S1\n" + + +@pytest.mark.asyncio +async def test_drainer_reads_and_deletes_only_the_isolated_queue(): + qf = paths.nerve_path("resume-after-restart") + assert (Path.home() / ".nerve") not in qf.parents + qf.parent.mkdir(parents=True, exist_ok=True) + qf.write_text("S1\n") + + engine = _engine_with({"S1": _session("S1")}) + assert await engine.resume_enrolled_sessions() == 1 + assert not qf.exists() diff --git a/tests/test_telegram_source.py b/tests/test_telegram_source.py new file mode 100644 index 00000000..c0c6b5ee --- /dev/null +++ b/tests/test_telegram_source.py @@ -0,0 +1,46 @@ +"""Tests for the Telegram source's session-file location.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from nerve import paths +from nerve.sources.telegram import TelegramSource + + +class TestSessionPath: + """Where Telethon's ``.session`` file lands. + + The session is the authenticated login; putting it somewhere unexpected + means the interactive ``nerve sync telegram`` writes one file and the cron + run looks for another, so the sync just keeps reporting that it is not + authenticated. + """ + + def test_default_is_under_the_state_dir(self): + src = TelegramSource({}) + assert src._session_path() == str(paths.nerve_path("telegram_sync")) + + def test_configured_path_is_honored(self, tmp_path): + src = TelegramSource({"session_path": str(tmp_path / "tg")}) + assert src._session_path() == str(tmp_path / "tg") + + def test_tilde_expands(self): + src = TelegramSource({"session_path": "~/tg-session"}) + assert src._session_path() == str(Path.home() / "tg-session") + + @pytest.mark.parametrize("blank", ["", " ", "\t", "\n", " \t "]) + def test_blank_falls_back_to_the_default(self, blank): + """Whitespace is truthy, so without the strip this becomes " .session". + + Relative to the cwd, so the file also moves with whatever directory the + daemon was started in. + """ + src = TelegramSource({"session_path": blank}) + assert src._session_path() == str(paths.nerve_path("telegram_sync")) + + def test_surrounding_whitespace_is_trimmed_not_baked_in(self, tmp_path): + src = TelegramSource({"session_path": f" {tmp_path / 'tg'} "}) + assert src._session_path() == str(tmp_path / "tg")