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")