From 10294128c908b258e5b624712f56c3cf73776f2f Mon Sep 17 00:00:00 2001 From: Medhat Galal Date: Tue, 17 Mar 2026 08:10:59 -0400 Subject: [PATCH] Fix duplicate Codex GSD agent blocks without marker --- bin/install.js | 17 ++++++++++++++--- tests/codex-config.test.cjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/bin/install.js b/bin/install.js index cff9bb5f8..68a87e3f2 100755 --- a/bin/install.js +++ b/bin/install.js @@ -857,6 +857,10 @@ function generateCodexConfigBlock(agents) { return lines.join('\n'); } +function stripCodexGsdAgentSections(content) { + return content.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); +} + /** * Strip GSD sections from Codex config.toml content. * Returns cleaned content, or null if file would be empty. @@ -882,7 +886,7 @@ function stripGsdFromCodexConfig(content) { cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*\n?/m, ''); // Remove [agents.gsd-*] sections (from header to next section or EOF) - cleaned = cleaned.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); + cleaned = stripCodexGsdAgentSections(cleaned); // Remove [features] section if now empty (only header, no keys before next section) cleaned = cleaned.replace(/^\[features\]\s*\n(?=\[|$)/m, ''); @@ -916,7 +920,7 @@ function mergeCodexConfig(configPath, gsdBlock) { let before = existing.substring(0, markerIndex).trimEnd(); if (before) { // Strip any GSD-managed sections that leaked above the marker from previous installs - before = before.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, ''); + before = stripCodexGsdAgentSections(before); before = before.replace(/^\[agents\]\n(?:(?!\[)[^\n]*\n?)*/m, ''); before = before.replace(/\n{3,}/g, '\n\n').trimEnd(); @@ -929,7 +933,14 @@ function mergeCodexConfig(configPath, gsdBlock) { // Case 3: No marker — append GSD block let content = existing; - content = content.trimEnd() + '\n\n' + gsdBlock + '\n'; + content = stripCodexGsdAgentSections(content); + content = content.replace(/\n{3,}/g, '\n\n').trimEnd(); + + if (content) { + content = content + '\n\n' + gsdBlock + '\n'; + } else { + content = gsdBlock + '\n'; + } fs.writeFileSync(configPath, content); } diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 4c2cd0aff..2ea3a91af 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -354,6 +354,36 @@ describe('mergeCodexConfig', () => { assert.ok(content.includes('[agents.gsd-executor]'), 'has agent'); }); + test('case 3 strips existing [agents.gsd-*] sections before appending fresh block', () => { + const configPath = path.join(tmpDir, 'config.toml'); + const existing = [ + '[model]', + 'name = "o3"', + '', + '[agents.custom-agent]', + 'description = "user agent"', + '', + '', + '[agents.gsd-executor]', + 'description = "old"', + 'config_file = "agents/gsd-executor.toml"', + '', + ].join('\n'); + fs.writeFileSync(configPath, existing); + + mergeCodexConfig(configPath, sampleBlock); + + const content = fs.readFileSync(configPath, 'utf8'); + const gsdAgentCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length; + const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length; + + assert.ok(content.includes('[model]'), 'preserves user content'); + assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section'); + assert.strictEqual(gsdAgentCount, 1, 'keeps exactly one GSD agent section'); + assert.strictEqual(markerCount, 1, 'adds exactly one marker block'); + assert.ok(!/\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block'); + }); + test('idempotent: re-merge produces same result', () => { const configPath = path.join(tmpDir, 'config.toml'); mergeCodexConfig(configPath, sampleBlock);