🤖 feat(backup): back up and restore portable top-level config settings - #4229
🤖 feat(backup): back up and restore portable top-level config settings#4229ibetitsmike wants to merge 9 commits into
Conversation
Settings backup exported only a portable subset of userPreferences, so agentAiDefaults, defaultModel, hiddenModels, advisor, task, heartbeat, goal, archive-behavior, runtime and layout settings were silently left out of export and restore. The new settingsProjection module owns a fail-closed allowlist of those top-level config.json keys. They ride inside preferences.json under a sibling `settings` key instead of a new payload file, so older builds (whose manifest parser refuses unknown paths but whose UserPreferencesSchema strips unknown keys) keep restoring the preferences they understand. Restore merges the block in the same config edit as the preferences, replaces carried keys wholesale, leaves machine-local keys alone, and extends the post-write verification.
Behavior-neutral cleanup of the portable settings backup: - settingsProjection.ts: shorten the save-path mirroring comment to three lines and reduce the readBackupSettings doc to its rationale. - settingsProjection.test.ts: build the projection expectation from one portable fixture instead of repeating it, and assert the merged task settings exactly rather than with a greater-than-zero check. - payload.test.ts: spread a preferences fixture instead of a rest destructure with a throwaway binding. Each tightened test was proven to fail with the guarded behavior removed.
…d settings fields A restore rewrites defaultModel, hiddenModels, agentAiDefaults, runtimeEnablement and defaultRuntime in config while the renderer keeps reading its startup-seeded localStorage mirrors, so the Models and Runtimes pages stayed stale and the next hide/unhide persisted the stale list over the restored one. The startup seeding moves into a shared helper that the Backup section reuses after a successful restore. A malformed settings block now reports the offending fields instead of the raw issue list.
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
Security findingsAdvisory findings (1)ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 116832b0d4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🛡️ Codex Security Review · Automatically triggered
Here are some automated security review suggestions for this pull request.
Reviewed commit: 116832b0d4
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
…d values The settings block is now a complete snapshot: every portable key is present, null meaning unset, so a setting the user reset to its default (cleared agent overrides, deleted fallback chains, full-width off) round-trips as that default instead of a gap the restore filled from the target. Export, read, merge and the post-write check share one normalizer table built from the same functions Config applies on save and load, so a schema-valid but non-canonical document no longer trips the post-write IO_ERROR after files were written. The renderer mirror re-seed is authoritative (a value the backend no longer holds clears the mirror) and UILayoutsProvider refreshes on config changes so restored layout presets take effect without a reload.
A restore no longer refuses the whole backup when the settings block holds a value this build's schema does not know (a thinking level or runtime added after it shipped, or a damaged document). Each such key keeps its local value and is named in the preview and restore results, which the Backup section shows, so a downgrade still restores everything else and the user knows what to reapply after updating.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aad7583a0b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… clear stale notices The on-disk schema leaves layoutPresets unknown, so a backup written by a build with a newer preset version passed the per-key check and its normalizer read the unknown version as empty, deleting the target's presets. The versions this build migrates are now pinned, so such a document is reported as unsupported and the local presets stay. UILayoutsProvider awaits each refresh in its config subscription so two quick changes cannot land out of order, and the Backup section clears the unsupported-settings notice wherever it invalidates the preview it came from.
… unlimited The advisor reads advisorMaxUsesPerTurn === null as "unlimited" and undefined as the default cap, while the settings block spells unset as null. A source at the default therefore exported null, and every restore switched the target's advisor to unlimited uses per turn. The export now writes the default cap for an unset value and keeps null only for a genuinely unlimited source.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b8b2bc380d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ors after a failed restore
A document such as {"version": 2, "slots": "corrupt"} passed the version-only check, after which the normalizer read the slots as empty and the restore deleted the target's presets. The backup field now uses the strict schema the layouts API saves through, so any preset document this build cannot parse is reported as unsupported. The Backup section also re-seeds the renderer mirrors when a restore fails after its safety snapshot, since config may already have been rewritten by then.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27c72b9bae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… config responses Codex round 9 on 27c72b9, three P2 findings in code this PR added: - The startup mirror seed wrote the runtime mirrors unconditionally, so a runtime toggle made while the initial getConfig() was pending was reverted by the stale response. The runtime keys are now tracked as dirty like the model keys and seedConfigMirrors honors skipKeys for them; the post-restore re-seed still clears authoritatively. - UILayoutsProvider ordered only subscription refreshes; the mount refresh, pre-write fetches and saves could still land last with older presets. One generation counter now covers every request that installs presets: only the newest one applies its result. - A transient getAll() failure on a subscription refresh replaced the loaded presets with the empty default. Post-load failures now keep the last loaded presets and only flag loadFailed. Tests: runtime toggle during a pending startup config load (and backend authority afterwards), out-of-order refresh responses, a refresh that started before a save, a failed refresh after a successful load, and the initial-load fallback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12327c891c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // | ||
| // This prevents stale in-memory state (captured by closures) from accidentally overwriting a | ||
| // newer config when multiple writes happen in sequence (e.g., delete layout → clear hotkey). | ||
| const generation = ++generationRef.current; |
There was a problem hiding this comment.
Retry invalidated pre-write layout reads
When a layout edit's getConfigForWrite() request captures old presets and a concurrent restore/config-change refresh advances the generation and installs newer presets, this guard prevents the old response from updating React state but the function still returns that stale normalized value. The caller then derives its edit from the old layout and saveAll() writes it over the restored presets; retry or discard invalidated pre-write reads instead of returning them.
AGENTS.md reference: AGENTS.md:L167-L167
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed as described: the generation guard keeps an invalidated pre-write read out of React state, but getConfigForWrite still returns it, so a layout edit racing a restore derives its edit from the pre-restore presets and saveAll writes them back. For the record: the read-modify-write window itself predates this PR (getConfigForWrite followed by saveAll was never serialized against an external config rewrite); what is new is that the guard makes the invalidation observable. This is the fourth consecutive review round with a new ordering finding in the layout mechanism (rounds 4/5, 7/8, 9/10, 11), so per the repository's non-convergence rule the next step is a scope decision by Mike rather than another fix: either re-read when the pre-write fetch was invalidated (bounded retry), or take layout presets and the live layout refresh out of this PR. Held open until that decision.
Xum replied on Mike's behalf.
| const parsed = fieldSchema(key).safeParse(block[key]); | ||
| if (parsed.success) { | ||
| setSetting(settings, key, NORMALIZE[key](parsed.data)); |
There was a problem hiding this comment.
Preserve local settings when normalization rejects a value
When a backup contains a scalar that passes the loose on-disk schema but its normalizer rejects—for example defaultModel: "garbage"—safeParse succeeds here and setSetting converts the normalizer's undefined result into the explicit null reset. Restoring that damaged document therefore deletes the target's valid default model instead of retaining it and reporting the field as unsupported; distinguish valid canonicalizable values from rejected ones before recording the setting.
AGENTS.md reference: AGENTS.md:L127-L127
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. normalizeOptionalModelString("garbage") returns undefined, setSetting recorded that as null, and the merge deleted the target's default model; the same path existed for a hidden-models list with a malformed entry, a fallbacks object whose every chain the sanitizer drops, an unknown runtime id in runtimeEnablement, and a schema-valid layout slot the normalizer drops. Fixed locally in 4d4f4b20 (validated: 413 backup tests, typecheck, lint; a new test fails without the fix): fieldSchema tightens those keys to what their normalizer accepts, so the values surface in unsupported and the local value is kept, while empty spellings ("", [], {}, no slots) still canonicalize to unset. Not pushed yet: it goes out together with the outcome of the scope decision on the sibling layout thread, so the PR takes one more review pair rather than two.
Xum replied on Mike's behalf.
|
Review loop paused for a scope decision (repository non-convergence rule): the Codex round-11 code review on
|
Summary
Settings backup now carries the portable top-level
config.jsonsettings and restores them: per-agent model overrides (agentAiDefaults: model, thinking level, reasoning mode, enabled and advisor flags, sub-agent profile), default and hidden models, minimum thinking levels, model fallbacks, advisor settings, task settings, heartbeat and goal defaults, transcript width, debug-log toggle, runtime enablement and default runtime, and layout presets. Before this change the backup exported only a subset ofuserPreferences, so a restore on another machine left every one of these at the target's values.Status
Head
12327c89. Paused for a scope decision (16:40Z). The automatic Codex pair on this head (rounds 11 and 12) completed: security clean; code review with two P2 findings. (1) Invalidated pre-write layout read: the generation guard keeps a stalegetConfigForWriteresponse out of React state but still returns it, so a layout edit racing a restore is derived from the pre-restore presets; the read-modify-write window predates this PR, the guard made it observable. This is the fourth consecutive round with a new ordering finding in the layout mechanism (rounds 4/5, 7/8, 9/10, 11), so under the repository's non-convergence rule the next step is Mike's decision, put to him in the authoring chat: bounded fix forward (re-read when the pre-write fetch was invalidated), or takelayoutPresetsand the live layout refresh out of this PR, or keep as-is with a reasoned pushback. (2) Normalization-rejected values reset the target: a value the loose on-disk schema accepts but load-time normalization rejects (defaultModel: "garbage", a malformed hidden-models entry, an unknown runtime id, a layout slot the normalizer drops) was recorded as anullreset, deleting the target's valid setting instead of being reported as unsupported. Confirmed and fixed locally in4d4f4b20(fieldSchematightened to what each normalizer accepts; empty spellings still mean unset; new test fails without it; 413 backup tests, typecheck, lint green), held unpushed so the branch takes one review pair after the decision. Since27c72b9bthis head carries the fixes for the three round-9 findings (runtime keys dirty-tracked at startup; one generation counter across everyUILayoutsProviderrequest; last loaded presets kept when a later refresh fails), each with a regression test that fails without it. CI on this head: every job green exceptCodex CommentsandRequired, which fail while Codex threads are open. No remote UAT round has run on12327c89; the delta since27c72b9b(UAT round 5, passed) is renderer ordering and startup seeding covered by unit tests. Caveat alongside the deferred items: UAT round 5 showed that restoringdefaultModel: nullclears the key and a second origin of the same backend re-imports its stale mirror on reload (V3-01..V3-03); the UI has no way to clear the default model, so this state is newly reachable through restore, and the pre-existing import condition alone does not make it safe (see Deferred). Earlier provenance (Mike's direct merge request, rounds 7 to 10 beyond the six-review ceiling set for this PR, theTest / UnitQuickJS flake and its baseline) is condensed in the ledger toggle.Background
Restoring a backup did not bring back the exec/plan model overrides configured under Settings > Agents, or any other top-level setting.
createBackupPayloadwrote AGENTS.md, agents, skills, global memory, redactedmcp.jsoncand apreferences.jsonprojected fromuserPreferencesalone; nothing read the rest of the config.Implementation
src/node/services/backup/settingsProjection.ts: a fail-closed allowlist of the portable keys with the exclusion rationale (machine/network-local values, governor secrets, credential-derived routing, destructive archive policies, legacy save-time projections, internal state). Keys are validated one at a time againstAppConfigOnDiskSchema(the on-disk schema is passthrough, so a picked object would carry any key of a repository-controlled document into the config).nullmeans unset, so a setting the user reset to its default (cleared agent overrides, deleted fallback chains, full-width off) round-trips as that default instead of a gap the restore fills from the target.agentAiDefaultsspells its empty state as{}, the value config load normalizes to. A key that is absent from the block (an older build's export) keeps the local value.NORMALIZEtable maps each key to the canonical in-memory value using the same functionsConfigapplies on save and load (exported fromsrc/node/config/index.tsfor that purpose). Export, read, merge and the post-write verification share it, so a schema-valid but non-canonical document (a padded model string, an unsanitized fallback chain) compares equal to whatconfig.jsonholds after the write instead of tripping the post-writeIO_ERRORafter files were written.preferences.jsonunder a siblingsettingskey. No new payload file and no schema bump: older builds refuse unknown payload paths while parsing the manifest, but their non-strictUserPreferencesSchemastrips the unknown key, so they keep restoring what they understand.editConfigedit as preferences (hiddenModelsalso marksmigrations.hiddenModelsInitialized, as the UI path does); preview flagspreferences.jsonas modified when settings differ.unsupportedSettings(same pattern asprojectBundleSkipped) and the Backup section shows them, so the user knows what to reapply after updating.seedConfigMirrors(authoritative: a value the backend no longer holds clears the mirror; startup dirty keys stay protected) and the Backup section re-seeds after a successful restore.UILayoutsProviderrefreshes ononConfigChangedso restored layout presets apply without a reload.Validation
ece81ce2verified export contents, exclusion of machine-local keys (including a grep of the whole backup repository for locally set marker values), preview/restore/re-preview, older-format and malformed backups, and found the stale-mirror clobber fixed in116832b0; round 2 on116832b0passed (clobber fix confirmed, cross-tab Models and Runtimes live, machine-local keys smuggled into the block ignored, sanity). Round 3 on the final headaad7583apassed every scenario: reset round-trip with the 19-key snapshot and no archive keys inpreferences.json, clobber and reload, Runtimes live, restored layout preset applied without a reload, unsupported-settings notice (a tamperedthinkingLevelskipped and named after Preview and after Restore, restore not refused, the validdefaultModelnext to it applied, local agent defaults untouched), and sanity on both roots. 27 unique screenshots plus the exportedpreferences.jsonand manifest. Round 4 onb8b2bc38passed every scenario (35 unique screenshots): unknown layout preset version and non-object document reported as unsupported with the local slot untouched and still applying, notice cleared by save, preview and backup, unset advisor cap exported as 3 and an explicit unlimited cap asnullwith both applied on the target, layout refresh ordering under rapid restore-and-apply cycles, and the round-3 regression set. The critic noted that the notice stays while aBack up nowattempt is in flight or fails; that is intended: a failed push does not replace the remote bundle, so the preview and the notice describing it remain accurate and are cleared together on success. Round 5 on the final head27c72b9b(27 unique screenshots, adjudicated PASS for the targeted scenarios): V1 corrupt layout shapes ({"version":2,"slots":"corrupt"},{"version":2,"slots":{"1":{"name":5}}}) reported as unsupported, restore completes,layoutPresetsbyte-identical before and after, slot 1 still applies; V2 a restore failing after its snapshot (project-memory file flipped read-only at the config rewrite by a timing watcher, a harness contrivance that was deterministic in three attempts) shows the snapshot path in the error, config already holds the restored default and hidden models, and the Models page reflects them live, keeps them when one more model is hidden, and after reload; V4 regression smoke (exec override reset live, 19-key block without archive keys, hidden-model clobber, layout version 3 unsupported, unset advisor cap exported as 3) passed; V5 sanity passed. V3 is an observation, not a pass: the manual control (clearing the default model in the UI) is not reachable because the UI has no way to clear it, and after restoringdefaultModel: nullat one origin a reload of a second origin (localhostversus127.0.0.1, same backend) re-imported that origin's stale default model into config, which the first origin then showed after its reload;hiddenModelswas unaffected. Cosmetic, pre-existing: the post-snapshot failure message joins two sentences without punctuation. Evidence under.mux-uat/round-5/in the authoring workspace.Configagainst a bare origin repository (including a source that reset its settings clearing the target's overrides), mirror re-seeding and clearing (including dirty runtime keys during the startup load), layout refresh ordering (out-of-order responses, a refresh completing after a save, a failed refresh after a successful load, the initial-load fallback).tests/ui/BackupSection.test.tshas 7 failures on this branch that reproduce identically on the base commit023701d; they are pre-existing and unrelated.Risks
nullresetting it to the default; only a key absent from the block leaves the target untouched. Restores now write into settings other services read at runtime (agent defaults, model fallbacks, task settings); every value passes the on-disk zod schema and the existing save/load normalization, and the safety snapshot taken before a restore also carries the settings block, so the undo path covers them.worktreeArchiveBehavior,coderWorkspaceArchiveBehavior) are deliberately not portable: a repository-controlled "delete" would make later archives destructive with no prompt naming the policy.WorkspaceContextstartup seeding moved into the shared helper; the one behavior change is that a backend without a stored default model or hidden list now clears the corresponding mirror instead of leaving a stale value. Dirty-key protection now covers the runtime keys as well as the model keys, so a toggle made during the initial config load survives it.Deferred (pre-existing, found during UAT)
Owner: Mike (this note is the follow-up record; issues to be filed on request). Trigger: after this PR merges, or earlier if one of them bites.
manifest.jsonsourceLabelis the exporting root's directory name.userPreferences(edit, then a synchronousloadConfigOrDefault()read-back compared through the backup projection); this PR widens the compared set to the settings block, so more writers can in principle trip it.editConfigserializes every read-modify-write behind a single permit, so a concurrent edit is derived from the restored config and no restored value is lost; the read-back runs synchronously in the continuation of the sameawait, so a competing edit must complete a file write first, which makes the window practically unreachable. Consequence if hit: a spuriousIO_ERRORnaming the safety snapshot, after which the Backup section re-seeds the mirrors from the backend (27c72b9b). Fix: haveeditConfigexpose its persisted result so the check binds to the edit.migrateLocalModelPrefsToBackendis unchanged by this PR; it imports a non-empty localStoragedefaultModelmirror whenever the backend has none, and a hidden-list mirror whilehiddenModelsInitializedis false. What this PR adds is one more way to reach the "no default model" state: a restore carryingdefaultModel: null. The restoring origin is re-seeded and does not re-import; the exposure is a second origin of the same config root (browser mode on another host or port, or Electron plus a browser) that was used while the model was set and loads again afterwards, which then re-imports its stale mirror. UAT round 5 reproduced exactly this (V3-01..V3-03) and found that the UI offers no way to clear the default model, so a cleared default is reachable only through restore; the pre-existing import condition therefore does not by itself establish that the newly reachable state is safe, and this stays a caveat on the feature until the import is gated (for example on a one-timemigrationsflag likehiddenModelsInitialized). AhiddenModels: nullcannot come from a real source because load-time seeding always materializes an array, and the hidden-list import is gated onhiddenModelsInitialized. Fix: a persisted "model preferences initialized" marker that ends legacy re-import.Review ledger (automatic code and security reviews count separately; rounds 7 to 12 were auto-triggered by pushes made on Mike's direct instructions and exceed the six-review ceiling originally set for this PR). CI note: `Test / Unit` on `27c72b9b` failed once in `WorkflowRunner > marks empty workflow returns as failed runs` with the QuickJS WebAssembly trap `Unreachable code should not be executed`, byte-identical to failures of unrelated tests in #3559 (run 27552085157) and #3695 (runs 32914518352, 32991170353); this diff touches no workflow or QuickJS file, the test passes locally, and the rerun passed.
116832b0aad7583a116832b0aad7583a116832b0aad7583a00f1503d, pushed inb8b2bc38. Security review: no new findings. No approval on this head.aad7583aplus local00f1503dadvisorMaxUsesPerTurnexported asnull, which the advisor reads as unlimited, so a restore from a default source switched the target to unlimited advisor uses; otherwise no blocker once00f1503dis pushed. Fixed inb8b2bc38, pushed.b8b2bc3827c72b9b; verification window deferred (pre-existing); mirrors after partial failure fixed in27c72b9b; legacy origin re-import deferred (pre-existing). No approval on this head.27c72b9b12327c89: runtime mirrors at startup, mount refresh ordering, presets blanked on refresh failure. No approval on this head.12327c894d4f4b20, unpushed. No approval on this head.Generated with
xum• Model:anthropic:claude-fable-5-1• Thinking:xhigh• Cost:$237.54