Centralize machine-local path resolution in nerve.paths (1/13) - #204
Open
alex-clickhouse wants to merge 1 commit into
Open
Centralize machine-local path resolution in nerve.paths (1/13)#204alex-clickhouse wants to merge 1 commit into
alex-clickhouse wants to merge 1 commit into
Conversation
This was referenced Jul 28, 2026
There was a problem hiding this comment.
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.pyand 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
~/.nervepath 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.
alex-clickhouse
marked this pull request as ready for review
July 28, 2026 11:59
alex-clickhouse
force-pushed
the
config-refactor/01-paths
branch
3 times, most recently
from
July 29, 2026 10:31
2654bec to
5066e68
Compare
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>
alex-clickhouse
force-pushed
the
config-refactor/01-paths
branch
from
July 30, 2026 10:06
5066e68 to
7a33fad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.yamlbesideconfig.local.yamlunder 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:
<workspace>/config/settings.yaml— git-tracked, portable, sharedconfig.yaml— machine-localconfig.local.yaml— machine-local secretsCutting 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 keepsport: ${PORT}andenabled: ${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. Andnerve reloadapplies 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-runshows the plan first. Only a legacy monolith moves — aconfig.yamlholding nothing but machine-local keys stays where it is — and originals are never deleted, just renamed to*.migratedbreadcrumbs.Secrets are the part to review, because the tracked layer is the file you commit. Before
settings.yamlis written they move into machine-localconfig.local.yaml(mode0600), 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 anenvorheadersmapping, where an MCPAuthorizationheader has no giveaway key name. Migration prints what it moved and flags what it left behind, but heuristics cannot be exhaustive, so readsettings.yamlbefore 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
config-refactor/01-pathsNERVE_HOMEmoves everything at onceconfig-refactor/02-env-vars${VAR}/${VAR:-default}interpolation, so secrets can come from the environmentconfig-refactor/03-config-subdir<workspace>/config/layer beneath the machine-local onesconfig-refactor/04-cron-moveconfig-refactor/05-cron-reloadCronService.reload()+POST /api/cron/reloadconfig-refactor/06-file-watcherconfig-refactor/07-validatenerve config validate— hard-failing bundle validation, drops straight into CIconfig-refactor/08-migrateconfig-refactor/09-git-syncnerve sync: fetch, validate, then merge, so an invalid bundle never lands on diskconfig-refactor/10-lockdownlockdownmode — tracked layer only, runtime writes to tracked config refusedconfig-refactor/11-self-modifyconfig-refactor/12-reload-allreload_allpath across sources, MCP, skills, gates and cron, reported per subsystem, plusnerve reloadconfig-refactor/13-repo-setupnerve config init-repo— scaffolds a config repo and the CI that guards itconfig-refactor/14-faviconconfig/, served at/favicon.ico— the first thing to use the subtree as a distribution channel rather than plumb itRead 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
watchfileswatcher 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
mainand that PRs 2–13 had to be dispatched by hand — that was wrong. The runs had stopped because this PR conflicted withmain, 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_escalateswas flaky here and onmain. 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.pyas the single provider for machine-local state and routes the scattered~/.nerve/...literals through it, soNERVE_HOMEmoves 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