fix(#2940): preserve codex config.toml content after the GSD marker on update (#3067)

* test(#2940): prove gsd-update discards codex config after the GSD marker

Failing-first regression for #2940. mergeCodexConfig Case 2 (marker present)
preserves content before the marker but discards everything from the marker to
EOF, so user/Codex-CLI settings ([model], [mcp_servers.*], [profiles.*]) added
after a fresh install are wiped on every update. Rows 1-2 assert trailing user
content survives; rows 3-7 cover idempotency, the #2406 leaked-section
non-regression, the [agents] namespace, both regions, and the zero-trailing
boundary.

* fix(#2940): preserve codex config.toml content after the GSD marker on update

mergeCodexConfig Case 2 (marker present) preserved content before the marker but
discarded everything from the marker to EOF, so user/Codex-CLI settings added
after a fresh install ([model], [mcp_servers.*], [profiles.*]) were wiped on
every gsd-update.

Route the trailing region through the existing stripLeakedGsdCodexSections,
which removes GSD's own managed/leaked sections (the bare [agents] table GSD
regenerates, legacy [agents.gsd-*], [[agents]]) while preserving genuine user
TOML. The marker comment line (and the optional codex_hooks ownership line) is
stripped from the trailing region first so it is not duplicated alongside the
regenerated block. #2406's de-dup still holds (leaked sections after the marker
are still stripped), and re-merge is idempotent.

* fix(#2940): use CRLF-safe regex in regression test (lint:ci no-crlf-fragile-split)

The Row 7 blank-line assertion used a bare \n which trips local/no-crlf-fragile-split
under Windows git-autocrlf. Use \r?\n.

* fix(#2940): correct Row 5 to valid single-[agents] TOML shape

The isolated adversarial review found Row 5 (bareAgentsAfterMarkerHandled)
constructed TWO [agents] tables (the GSD-managed one + a user trailing one),
which is invalid TOML — a duplicate table definition. extractCodexUserAgentsScalars
reads only the first [agents] and stripLeakedGsdCodexSections removes ALL bare
[agents] tables, so the user's max_threads would be silently dropped.

The valid, realistic shape is the user folding max_threads INTO the managed
[agents] block (which the existing spliceCodexAgentsScalars path preserves) plus
a separate trailing [model]. Reword Row 5 to that shape. A duplicate-[agents]
input is invalid TOML Codex itself rejects, so it is out of scope for this fix.

* chore(#2940): add changeset fragment

* chore(#2940): backfill changeset PR number 3067

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-04 22:51:27 -04:00
committed by GitHub
parent c899f5ada3
commit c9dd4aa951
3 changed files with 227 additions and 5 deletions

View File

@@ -6322,17 +6322,40 @@ function mergeCodexConfig(configPath, gsdBlock) {
const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
// Case 2: Has GSD marker — truncate and re-append
// Case 2: Has GSD marker — preserve user content on BOTH sides, regenerate the GSD block.
//
// #2940: the marker delimits where GSD's OWN block begins, NOT where every post-marker byte
// is GSD-owned. A fresh install writes the GSD block as the file's entire content, so any
// settings the user or Codex CLI later adds ([model], [mcp_servers.*], [profiles.*]) land
// AFTER the block. The previous truncate-to-marker logic discarded that trailing region on
// every update, destroying user config. The fix routes the trailing region through the
// existing AST-based `stripLeakedGsdCodexSections`, which removes GSD's own managed/leaked
// sections (the bare [agents] table GSD regenerates, legacy [agents.gsd-*], [[agents]])
// while preserving genuine user TOML — so #2406's de-dup still holds AND user content survives.
if (markerIndex !== -1) {
let before = existing.substring(0, markerIndex).trimEnd();
if (before) {
// Strip any GSD-managed sections that leaked above the marker from previous installs
before = stripLeakedGsdCodexSections(before).trimEnd();
atomicWriteFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
} else {
atomicWriteFileSync(configPath, normalizedGsdBlock + eol);
}
// Capture and preserve genuine user content AFTER the GSD-managed region. The whole
// post-marker region is passed through stripLeakedGsdCodexSections: GSD's own previously-
// emitted [agents] table (regenerated above as normalizedGsdBlock) and any leaked sections
// are removed, while user tables ([model], [mcp_servers.*], [profiles.*]) are kept. The
// marker comment line itself (and the optional codex_hooks ownership line right under it)
// is GSD-owned and is stripped from the trailing region so it is not duplicated alongside
// the freshly regenerated block.
const rawAfter = existing.substring(markerIndex);
const markerStripped = rawAfter
.replace(GSD_CODEX_MARKER, '')
.replace(/^\r?\n# GSD codex_hooks ownership: (?:section|root_dotted)\r?\n/, '');
const afterUser = stripLeakedGsdCodexSections(markerStripped).trim();
const parts = [];
if (before) parts.push(before);
parts.push(normalizedGsdBlock);
if (afterUser) parts.push(afterUser);
atomicWriteFileSync(configPath, parts.join(eol + eol) + eol);
return;
}