Skip to content

Centralize machine-local path resolution in nerve.paths (1/13) - #204

Open
alex-clickhouse wants to merge 1 commit into
mainfrom
config-refactor/01-paths
Open

Centralize machine-local path resolution in nerve.paths (1/13)#204
alex-clickhouse wants to merge 1 commit into
mainfrom
config-refactor/01-paths

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Stacked PR 1/14. Base: main. The diff shown is this branch's own change; everything below it is reviewed in its own PR.

The refactor in one place

Config today is a machine-local monolith — config.yaml beside config.local.yaml under a hard-coded home directory, with cron jobs in a third location. None of it is shareable between machines or reviewable before it takes effect, and changing anything means editing a file on the box and restarting the daemon.

This stack makes config a git-tracked, validated, hot-reloadable bundle that lives in the workspace repo, while machine-local files keep overriding it for secrets and per-box paths. End state — three layers, lowest precedence first:

  1. <workspace>/config/settings.yaml — git-tracked, portable, shared
  2. config.yaml — machine-local
  3. config.local.yaml — machine-local secrets

Cutting across all three: any value can be a ${VAR} or ${VAR:-default} reference, resolved from the environment once after the layers are merged (PR 2, #205). A secret therefore need not live in a file at all — the tracked layer can name it and the environment can supply it. Because interpolation always yields a string, values are converted back to their declared type afterwards, which is what keeps port: ${PORT} and enabled: ${FLAG} behaving like literal YAML.

Every step is backwards compatible on its own: an install that never adopts the new layout keeps working, and the migration in PR 8 (#211) is what moves it.

How a change reaches a running instance

Two paths, and no others. A workspace sync pulls the merged commit and applies it — fetch, validate, then fast-forward, so an invalid bundle never lands on disk (PR 9, #212). The loop runs every workspace_sync.interval_minutes, default 1, which is therefore the upper bound on how stale an instance can be. And nerve reload applies an edit made on the box, on demand (PR 12, #215).

No config file applies itself when it changes on disk. That is deliberate: a hand edit is exactly the change nobody reviewed, and on a locked instance the contract is that the running config came from the tracked repo — sync already refuses to merge while config/ has local modifications, so hot-applying the same edit from a file event would contradict it. Editors also write in pieces, and a reload re-points the whole process.

Whichever path applies it, the reload reports per subsystem what took the change and what needs a restart instead (#215), because a reload that claims success while half the process runs the old config is worse than one that fails.

Migration, and what happens to secrets

Migration runs automatically on daemon start, is idempotent, and is a no-op once done; nerve migrate --dry-run shows the plan first. Only a legacy monolith moves — a config.yaml holding nothing but machine-local keys stays where it is — and originals are never deleted, just renamed to *.migrated breadcrumbs.

Secrets are the part to review, because the tracked layer is the file you commit. Before settings.yaml is written they move into machine-local config.local.yaml (mode 0600), replaced by ${ENV_VAR} placeholders: secret-looking key names, values whose shape is a credential whatever the key is called (sk-…, ghp_…, user:pass@host), and every value under an env or headers mapping, where an MCP Authorization header has no giveaway key name. Migration prints what it moved and flags what it left behind, but heuristics cannot be exhaustive, so read settings.yaml before the first commit — one manual step, once per install. The config-repo CI in PR 13 (#216) runs gitleaks ahead of the validator as a backstop.

Where each PR fits

PR Branch Phase What it adds
1 (#204) config-refactor/01-paths Foundations This PR. One provider for machine-local dirs, so NERVE_HOME moves everything at once
2 (#205) config-refactor/02-env-vars Foundations ${VAR} / ${VAR:-default} interpolation, so secrets can come from the environment
3 (#206) config-refactor/03-config-subdir Foundations The git-tracked <workspace>/config/ layer beneath the machine-local ones
4 (#207) config-refactor/04-cron-move Cron Cron jobs, system jobs and gate plugins move into that subtree (legacy fallback kept)
5 (#208) config-refactor/05-cron-reload Cron CronService.reload() + POST /api/cron/reload
6 (#209) config-refactor/06-file-watcher Cron Graceful shutdown for supervised background tasks, so teardown cannot leave config-derived state half-applied
7 (#210) config-refactor/07-validate Guardrails nerve config validate — hard-failing bundle validation, drops straight into CI
8 (#211) config-refactor/08-migrate Guardrails Idempotent migration of a legacy monolith into the tracked layer, secrets scrubbed out
9 (#212) config-refactor/09-git-sync Distribution nerve sync: fetch, validate, then merge, so an invalid bundle never lands on disk
10 (#213) config-refactor/10-lockdown Lockdown lockdown mode — tracked layer only, runtime writes to tracked config refused
11 (#214) config-refactor/11-self-modify Lockdown Under lockdown the agent proposes config changes as a PR instead of editing files
12 (#215) config-refactor/12-reload-all Rollout One reload_all path across sources, MCP, skills, gates and cron, reported per subsystem, plus nerve reload
13 (#216) config-refactor/13-repo-setup Rollout nerve config init-repo — scaffolds a config repo and the CI that guards it
14 (#224) config-refactor/14-favicon Rollout A favicon tracked in config/, served at /favicon.ico — the first thing to use the subtree as a distribution channel rather than plumb it

Read bottom-up; each PR's diff is only its own change. Reviewing PRs 1–3 first is worth it — the later ones all rest on the layering they establish.

PR 6's branch name is a leftover: it was a watchfiles watcher over the cron directory, and that was dropped in favour of the two apply paths above. See #209 for the reasoning and the line count.

Reviewing this stack

Checks run on all 14 PRs. An earlier revision of this section claimed CI only triggered against main and that PRs 2–13 had to be dispatched by hand — that was wrong. The runs had stopped because this PR conflicted with main, which suppresses checks for the whole stack; rebasing fixed it, and runs appeared within seconds. A missing check mark on any PR here is worth investigating, not expected.

Each commit is squashed from a longer review history, and the full suite runs on every branch with no exclusions — 1870 tests at PR 1, 2658 at PR 13, 2677 at PR 14.

A previous revision of this section warned that test_review_loops.py::test_gamed_verdict_escalates was flaky here and on main. It was, and it has since been fixed upstream — the test waits for the decision card now instead of asserting on it synchronously. This stack is rebased onto that fix, so a failure there is a real one.

This PR

Adds nerve/paths.py as the single provider for machine-local state and routes the scattered ~/.nerve/... literals through it, so NERVE_HOME moves everything at once instead of everything-but-a-few.

A path built from a literal home fails quietly: both locations exist and both are writable, so the CLI writes one file and the daemon reads another. The guard test therefore fails on the pattern rather than on a symptom, and matches only path construction — the many comments naming the default location are fine.

Pure refactor; unblocks moving config into the workspace.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Centralizes machine-local runtime path resolution behind a new nerve.paths module so a single NERVE_HOME override relocates all state consistently (and avoids silent split-brain between CLI/daemon writing different ~/.nerve-derived paths).

Changes:

  • Introduces nerve/paths.py and routes machine-local paths (db/log/pid/cron/config pointer/etc.) through it across CLI, config, bootstrap, gateway, proxy, memory, and sources.
  • Hardens config path parsing so blank/whitespace path settings mean “unset” (preventing accidental Path(".") fallbacks).
  • Expands/updates tests to enforce “no literal ~/.nerve path construction” and ensure pytest isolation doesn’t touch a developer’s real state dir.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_resume_after_restart.py Strengthens restart/resume tests to ensure queue file is always under isolated NERVE_HOME.
tests/test_proxy.py Updates proxy default path expectations to use nerve.paths.
tests/test_paths.py Adds comprehensive tests for nerve.paths behavior + guardrails against bypassing it.
tests/test_config_resolution.py Adds coverage for blank path settings behaving as “unset” defaults.
tests/test_bootstrap.py Updates bootstrap tests for cron paths and Docker env/mount consistency with NERVE_HOME.
tests/conftest.py Switches test isolation to NERVE_HOME + repoints import-time path constants in-memory.
nerve/sources/telegram.py Uses nerve.paths for the default Telethon session path.
nerve/proxy/service.py Moves proxy config path under NERVE_HOME via nerve.paths.
nerve/paths.py New central provider for machine-local runtime directories and well-known paths.
nerve/pairing.py Routes Telegram pairing file location through nerve.paths.
nerve/memory/memu_bridge.py Routes memu resource/conversation directories through nerve.paths.
nerve/gateway/server.py Uses nerve.paths for DB path, backups, and retired-artifact cleanup paths.
nerve/gateway/routes/external_agents.py Uses paths.config_pointer_file() instead of hard-coded pointer path.
nerve/db/init.py Defaults DB path via paths.db_path().
nerve/cron/gates.py Routes cron gate cache state dir through paths.cache_dir().
nerve/config.py Uses nerve.paths for state paths; makes blank path config values resolve as None (unset).
nerve/cli.py Removes module-level PID/log paths and resolves them lazily via nerve.paths; fixes pointer label to avoid import-time freezing.
nerve/bootstrap.py Uses nerve.paths for state/cron/init-state; aligns Docker templates/mounts with explicit NERVE_HOME/NERVE_WORKSPACE.
nerve/agent/tools/handlers/memory.py Routes manual memu file writes through nerve.paths.
docs/setup.md Documents Docker env vars and notes workspace now includes config/.
docs/config.md Documents path expansion + “blank means unset” semantics (including TLS cert/key behavior).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nerve/sources/telegram.py Outdated
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 28, 2026 11:59
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/01-paths branch 3 times, most recently from 2654bec to 5066e68 Compare July 29, 2026 10:31
Every machine-local directory now comes from one provider instead of ~/.nerve
literals scattered across config, cli, bootstrap, pairing, cron gates and the
engine. NERVE_HOME is honoured and normalized to an absolute path, so a relative
value cannot silently resolve against whatever directory the process started in,
and Docker artifacts are routed through the provider rather than hardcoded. CLI
PID and log paths are resolved lazily: computing them at import time froze them
before NERVE_HOME could take effect.

A blank path setting now means unset rather than the working directory.
Path("") is Path("."), which is truthy, so every `or <default>` fallback was
skipped and a blank value silently resolved to the daemon's cwd — including
gateway.ssl.cert, where it made SSLConfig.enabled true with "." as the
certificate. Blank, whitespace-only, and blank-after-expansion all return None.

The path settings that stay str carry the same rule separately, because they are
expanded downstream and so never reach that helper: the codex origin path and
archive_path, codex home_dir, backup target_dir, cron prompt_file and the
Telegram session_path. The `or <default>` idiom alone catches "" and a missing
key but not "  ", which is truthy and became a directory named for a space,
relative to wherever the process started. Two of these are worse than a stray
directory: prompt_file outranks an inline prompt, so a blank one failed every run
while a usable prompt sat unused; and the Telegram session is the authenticated
login, so the interactive sync wrote one file while cron looked for another and
reported only that it was not authenticated.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants