Auto-migrate legacy config into workspace/config (scrub + backup) (8/13) - #211
Open
alex-clickhouse wants to merge 2 commits into
Open
Auto-migrate legacy config into workspace/config (scrub + backup) (8/13)#211alex-clickhouse wants to merge 2 commits into
alex-clickhouse wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an idempotent “legacy → workspace/config” migration path that scrubs secrets into the machine-local overlay, leaves *.migrated backups, and runs automatically on upgrade/start (plus a nerve migrate command). This supports the broader config refactor by safely moving shareable settings and cron config into the workspace-tracked layout while keeping secrets and machine-local paths out of the tracked layer.
Changes:
- Introduce
nerve.migrateto migrate legacyconfig.yaml+ legacy cron intoworkspace/config/with secret scrubbing and backups. - Add
nerve.utils.fs.atomic_write_textfor crash-safe, permission-aware atomic writes and use it in the Codex Ultracode backend. - Wire migration into
nerve start,nerve upgrade, and add anerve migrate [--dry-run]CLI command; document the migration behavior and add extensive tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
tests/test_utils_fs.py |
Adds coverage for atomic, permission-correct file writing semantics. |
tests/test_migrate.py |
Adds comprehensive tests for config/cron migration, secret scrubbing, idempotency, and crash-safety. |
nerve/utils/fs.py |
Introduces a shared atomic text writer with mode handling and durability guarantees. |
nerve/migrate.py |
Implements legacy-to-workspace migration (scrub + backup + cron move) with reporting. |
nerve/cli.py |
Runs migration on start/upgrade and adds a migrate subcommand. |
nerve/agent/backends/codex/ultracode.py |
Replaces local atomic writer with the shared atomic_write_text. |
docs/config.md |
Documents migration behavior, limits, and operator guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 28, 2026 12:11
c2eaddd to
127f793
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 28, 2026 12:36
127f793 to
786b30d
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 28, 2026 13:14
786b30d to
c3960af
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 28, 2026 14:23
c3960af to
be06ac8
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 28, 2026 14:54
be06ac8 to
840007a
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
2 times, most recently
from
July 28, 2026 16:03
5b7ae3a to
9d63d48
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
2 times, most recently
from
July 28, 2026 18:40
16b41ef to
f4fab4f
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 29, 2026 08:29
f4fab4f to
a370972
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 29, 2026 09:18
a370972 to
ab5dee0
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 29, 2026 09:31
ab5dee0 to
145d1c0
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 29, 2026 10:31
145d1c0 to
75bf9c3
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 29, 2026 13:28
75bf9c3 to
bc27e72
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 30, 2026 08:01
bc27e72 to
1038bbd
Compare
alex-clickhouse
marked this pull request as ready for review
July 30, 2026 08:10
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 30, 2026 10:06
1038bbd to
a2a987b
Compare
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 31, 2026 08:19
a2a987b to
6fc9ff5
Compare
One idempotent routine copies the legacy layout into <workspace>/config/, leaving
the originals as breadcrumbs, and scrubs secrets on the way: known sensitive keys
move to the machine-local overlay and the tracked file gets an ${ENV_VAR}
reference in their place. A value that cannot be classified confidently stays
local rather than being committed, and migration prints exactly what moved. It
runs from upgrade and daemon start, no-ops once migrated, and --dry-run mutates
nothing.
Migration is isolable — it honours NERVE_HOME instead of reaching for real
machine state — crash-safe, and writes owner-only. A config.yaml that is already
the machine half is not migrated a second time, and a workflow journal directory
is not mistaken for shareable config. Rewriting a file no longer re-permissions
it.
Co-Authored-By: Claude <noreply@anthropic.com>
Migration moved everything except `workspace` and `lockdown` into the git-tracked `settings.yaml`, under a header reading "Safe to commit". A pre-split install therefore published its TLS certificate paths, AWS SSO profile, mail accounts, docker mounts, external-agent targets and workflow journal directory into a file the operator is told to commit, and nothing downstream objected: `nerve config validate --portable-only --strict-keys` passes, and the scaffolded gitleaks step matches credential shapes, not filesystem paths. `_MACHINE_LOCAL_PATHS` was not the cause. It decides whether a `config.yaml` is a legacy monolith at all, and was never consulted when copying — which is why `gateway.ssl` and `provider.aws_profile` were published despite being listed in it. The copy is now partitioned through that same predicate, so the list finally means what its name says. The split is by path depth, not by top-level key: `gateway.ssl` moves the subtree while `gateway.host` and `gateway.port` stay portable. Moving the whole key would send the bind address to a layer lockdown does not read. The machine half is written back to `config.yaml`, which is where `docs/config.md`'s layer table puts these keys and what `nerve init` already produces. Not `config.local.yaml`: that layer has higher precedence, and `routes/external_agents.py` rewrites the `external_agents` block into `config.yaml` on every enable/disable, so an overlay copy would shadow the gateway's own writes and every toggle would silently do nothing. `workspace` keeps going to `config.local.yaml`, which is written before anything is consumed — it is the one key whose loss is unrecoverable. `test_migration_still_publishes_machine_local_keys` pinned this as a known gap and named this fix in its docstring; it now pins the guarantee instead. A new guard parses the layer table out of `docs/config.md` and requires it to agree with `_MACHINE_LOCAL_PATHS` in both directions, and requires every entry to resolve against a default `NerveConfig` so a renamed field cannot sit in the list checking nothing. It found one real drift on its first run: `workflows.runs_dir` was in the code and missing from the table. Co-Authored-By: Claude <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
config-refactor/08-migrate
branch
from
July 31, 2026 08:36
6fc9ff5 to
caf5824
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.
When it runs
Three entry points, all calling the same routine:
nerve startandnerve upgrade, automatically, and only when config loaded. Best-effort:maybe_migratenever raises, so a failure here cannot stop the daemon. Onstartthis happens before the already-running check, so it also fires when a daemon is already up.nerve migrate [--dry-run], explicitly. This one is a self-diagnosing command, so it runs even when config fails to load (the workspace is then resolved from the machine-local files rather than the loaded object), and it exits non-zero on failure instead of swallowing it.The config half runs only if all three hold:
<config_dir>/config.yamlexists, whereconfig_diris~/.nerveor whereverNERVE_HOMEpoints.<workspace>/config/settings.yamlcarries no configuration: absent, or parsing to an empty mapping. The comments-only filenerve initscaffolds counts as empty, deliberately — treating it as "already migrated" made migration a permanent no-op for every install created after that scaffold shipped. Asettings.yamlthat will not parse counts as content, because migration must never overwrite what it cannot read.config.yamlholds at least one key outside_MACHINE_LOCAL_PATHS, which is what makes it a legacy monolith. A post-splitconfig.yamlholding nothing but machine-local keys is left exactly where it is.If
settings.yamldoes have content whileconfig.yamlstill holds shareable keys, nothing is migrated and the mismatch is reported instead. That state and an interrupted migration are indistinguishable on disk, so it says so rather than guessing.The cron half runs only if the legacy cron directory exists and at least one file in it, ignoring
*.migratedbreadcrumbs, has no counterpart at the same relative path under<workspace>/config/cron/. The workspace is whatconfig.workspacepoints at, or whatconfig.yaml/config.local.yamlname, falling back to the default; the legacy directory ispaths.cron_dir(), underNERVE_HOME.Both halves are idempotent, both are no-ops once migrated, and
--dry-runwrites nothing.What
One idempotent routine that moves a pre-refactor install to the new layout, in two independent halves.
Config. A legacy monolithic
config.yamlis copied into the tracked layer as<workspace>/config/settings.yaml, secrets move into the machine-local overlay as${ENV_VAR}references, and the original is tightened to owner-only and left as a*.migratedbreadcrumb so it stops shadowing the file that replaced it.Cron. The legacy
~/.nerve/cron/becomes<workspace>/config/cron/, which puts jobs in the tracked workspace alongside everything else. The whole directory is copied rather than justjobs.yamlandsystem.yaml, because a job can reference a sibling by relative path (prompt_file: prompts/daily.md) and taking only the two files migration knows about would break it. Nothing already present in the workspace is overwritten. Only those two files are then renamed to breadcrumbs, which is what stops the legacy directory reading as "has jobs" on a later pass. Because that pass can happen more than once, breadcrumb names are allocated to a free suffix (.migrated,.migrated.1) rather than a fixed one:Path.renameoverwrites silently on POSIX, and a fixed suffix would let a second pass destroy the only surviving copy of an earlier original.Gate plugins come with it, and the copy is per file.
cron/gates/*.pyis custom gate code the daemon imports at startup and on every reload, and losing it is not a soft failure:build_gatesraises on an unknown gatetype, and that takes the whole job with it (skipped with an error at startup, a400refusing the change set at reload). That is deliberate upstream of this PR, because silently dropping a gate would make a job run more often than its config asks for.Which is why the cron half copies file by file instead of testing whether the workspace already has cron config. The two sides overlap in the ordinary upgrade:
nerve initcreates<workspace>/config/cron/gates/empty, writessystem.yaml, and copies onlyjobs.yamlacross. A directory-level test skipped everything else permanently, because the legacy directory stops being consulted the moment the workspace has job files, sogates/*.pyand anyprompts/a job names by relative path were stranded where nothing reads them and custom gates quietly stopped loading. Per file, agates/that already exists is merged rather than skipped whole.A file already in the workspace is never overwritten; that copy is the reviewed one. The one case worth reporting is a legacy
jobs.yamlorsystem.yamlthat loses to one already in place: those are real cron jobs that now run nowhere, migration cannot merge two sets of jobs, and it is not breadcrumbed either, since renaming it would hide the operator's only copy. Gate and prompt files are copied and not breadcrumbed, so the legacy copies stay readable to anything mid-upgrade still resolving them by path. This also puts executable gate code into the reviewable git-tracked subtree, which is where the lockdown work downstream expects to find it.Both halves run automatically when the old layout is detected, and are exposed as
nerve migrate [--dry-run]. Being independent matters for the common case of an install that has already split its config by hand: it still gets its cron directory moved._MACHINE_LOCAL_PATHSis the single list of what stays on the box, used to tell a legacy monolith from an already-splitconfig.yaml. Two tests check it in both directions: one drives the wizard and fails if it emits a machine-local path the list does not cover, the other migrates a monolith and fails if a shareable key is left behind.Why
Upgrading in place has to work without the operator reading a migration guide, and it has to be safe to run twice — hence idempotent, never destructive, and best-effort so a failure at daemon start does not stop the daemon.
Scrubbing is the part that needs care, because the destination is a file the docs tell you to commit. Secrets are identified by key name, by value shape (an
sk-/ghp_token, auser:pass@hostDSN, a token in a query string — the half a key-name list can never catch), by being anywhere inside anenvorheadersmapping where arbitrarily-named secrets live, and by position: the argv item after a flag that names a credential. That last one matters because--token=abcis a single string that can be matched on shape while["--token", "abc"]splits the name off the value, and the value alone is unrecognizable — no prefix, no separator, whatever length the vendor chose. Split argv is the formnpxanduvxMCP servers are configured with, so without it the common case went to the tracked file in plaintext and was not even flagged as suspect. A flag does not arm another flag:--token --verboseis a malformed command line, not a token named--verbose.Heuristics cannot be exhaustive, so migration prints what it moved and anything left behind that still looks credential-shaped, and says to review the file before committing.
Telling the two shapes of
config.yamlapart is load-bearing in both directions. An emptiedsettings.yamlis not evidence that aconfig.yamlis a monolith — a workspace loses its settings file by being repointed, moved, or interrupted mid-init — so the test is a positive one on the machine file's contents. And entries in the list are scoped to the part that is genuinely local: listing a whole subtree keeps every key under it out of the tracked layer, which is whygatewayandproviderwere wrong as whole subtrees. A legacy install's bind port and provider type stayed inconfig.yaml, which lockdown does not read.Two keys get special handling on the way across.
workspacestays machine-local, or the tracked file would name a path that only resolves on one box.lockdownis dropped, with a note: the machine layers ignore it by design so that a local edit cannot unlock or fake-lock an instance, and copying it intosettings.yamlwould promote a flag that has never had any effect into the one file where it is authoritative. Locking then drops the machine layers, including theconfig.local.yamlthis migration just moved the secrets into, so the next start would fail on a${VAR}it used to resolve. Dropping it preserves what the box already does.Migration is also not one transaction, and it is not treated as one. The config half commits before the cron half runs, so an exception in the cron half leaves
settings.yamlwritten, the secrets relocated andconfig.yamlrenamed away.maybe_migratereturns the partial report rather thanNonefor that: reporting it as "nothing happened" madenerve upgradeprint no review prompt for a tracked file that now exists and may hold an unscrubbed credential, and madenerve startskip the config reload it needs once the files have moved underneath it. The log distinguishes "skipped" from "half-applied", andupgradenames what did apply and says to re-run.Why a new
atomic_write_text()Because migration rewrites
config.local.yaml, and that file already holds secrets that exist in no backup anywhere: the wizard puts the API key, the OAuth token and the password hash there. Migration merges its own additions into whatever is present, so that rewrite is the one step in the flow that can destroy a credential outright. Scrubbed secrets are recoverable from the*.migratedbreadcrumb; the operator's pre-existing local values are not.The codex backend already had a private
_atomic_writedoing temp-file-plus-rename. It was not sufficient here, on four counts:replace()is atomic against the process dying, not against the machine dying. Renaming a file whose bytes are still only in page cache is how ext4 delayed allocation produces a zero-lengthconfig.local.yamlafter a power loss, which is worse than either of the two outcomes the rename exists to guarantee. The parent directory has to be flushed too, or the rename itself is not durable..{name}.{pid}.tmp, written with a plainwrite_text, follows a symlink planted at that path and puts the secret wherever it points.mkstempusesO_EXCLand an unpredictable name.settings.yamlis meant to be shareable andconfig.local.yamlowner-only, and rewriting either must not silently re-permission it.mode=Nonereproduces whatopen(path, "w")would have left, including on a file that already exists.It also writes through a symlinked destination instead of replacing the link with a regular file, which is what a
config.local.yamllinked into a dotfiles repo needs.Converting the codex backend's copy to the shared helper picks up the same four fixes there.
nerve/external_agents/writer.pystill has a third copy with the same weaknesses; it writes~/.codex/config.tomlrather than anything Nerve owns, so it is left for a follow-up.Added in review
Migration published machine-local configuration into the tracked file. It
moved everything except
workspaceandlockdownintosettings.yaml, under agenerated header reading "Safe to commit". A pre-split install therefore
published its TLS certificate paths, AWS SSO profile, mail accounts, docker
mounts, external-agent targets and workflow journal directory into the file the
runbook tells the operator to commit. Nothing downstream objected:
nerve config validate --portable-only --strict-keyspasses, and the scaffolded gitleaks stepmatches credential shapes, not filesystem paths.
_MACHINE_LOCAL_PATHSwas not the cause. It decides whether aconfig.yamlis alegacy monolith at all, and was never consulted when copying — which is why
gateway.sslandprovider.aws_profilewere published despite being listed init. The copy is now partitioned through that same predicate, so the list means
what its name says.
The split is by path depth, not by top-level key:
gateway.sslmoves the subtreewhile
gateway.hostandgateway.portstay portable. Moving the whole key wouldsend the bind address to a layer lockdown does not read.
The machine half is written back to
config.yaml, which is where the layer tableputs these keys and what
nerve initalready produces — notconfig.local.yaml,which has higher precedence and would shadow the
external_agentsblock thatroutes/external_agents.pyrewrites intoconfig.yamlon every toggle.test_migration_still_publishes_machine_local_keyspinned this as a known gapand named this fix in its docstring; it now pins the guarantee. A new guard
requires
_MACHINE_LOCAL_PATHSand thedocs/config.mdlayer table to agree inboth directions, and every entry to resolve against a default
NerveConfig. Itfound one real drift on its first run:
workflows.runs_dirwas in the code andmissing from the table.
Still open, deliberately:
codex.home_dir,codex.bin_path,ollama.hostandmemory.sqlite_dsnare per-box values that neither the table nor the listclaims. Widening the list also changes what counts as a legacy monolith, so it is
not a free edit.
🤖 Generated with Claude Code