fix(#2760): defensive Codex install — strip legacy agents blocks, default hooks to AoT, validate post-write schema (#2785)
* fix(#2760): defensive Codex install — strip legacy agents blocks, default hooks to AoT, validate post-write schema Three defects, three defensive fixes shipped together. Issue reporter never returned with the requested diagnostic backup, but four additional users have since confirmed the same Codex breakage and ZakAnun confirmed manual cleanup is the only working workaround — defensive triple ships without the original backup grep, justified by the corroborating reports. Fix 1 (defect 3 — confirmed real). The Codex hooks emit path always appended a top-level `[[hooks]]` AoT block, which collides with users who already use the namespaced AoT form `[[hooks.SessionStart]]`. New helper `hasUserNamespacedAotHooks()` detects the user's preferred shape on parse and the install emits the GSD-managed hook in that same shape when present. Default for fresh configs stays at top-level `[[hooks]]` so status-quo behavior is preserved. Fix 2 (defects 1+2 — defensive). `stripLeakedGsdCodexSections()` (the install-time stripper) now always purges bare `[agents]` single-bracket tables and `[[agents]]` sequence tables regardless of GSD marker presence — both forms are invalid in current Codex schema and produce "invalid type: ..., expected struct AgentsToml". Previously gated on GSD-name lookup which missed marker-stripped configs and third-party authored entries. The uninstall-time stripper (`stripCodexGsdAgentSections`) keeps its old conservative behavior so user-authored entries survive uninstall. Fix 3 (defensive). Post-write schema validation parses the bytes about to be committed and asserts no bare `[agents]`, no `[[agents]]`, and no bare `[hooks.<Event>]` tables remain. On failure the install restores the pre-install backup of config.toml and aborts loudly so the user is never left with a Codex CLI that refuses to load. Pre-install snapshot is captured before installCodexConfig runs (not after) so restore returns the file to its true pre-GSD state. Tests added (10 new, 1 updated): - bug-2760-codex-install-defensive.test.cjs (10 new tests across 4 describes: hooks AoT preservation, strip robustness for both [agents] and [[agents]] without marker, schema validator behavior, abort+restore via test seam) - codex-config.test.cjs "case 2 ..." updated to reflect new defensive bare-[agents] purge Full suite: 5747 pass / 0 fail. Closes #2760 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#2760): normalize Codex hooks emit field name across migration and managed paths The migrateCodexHooksMapFormat path emitted `type = "<TYPE>"` for legacy [hooks.TYPE] sections, while the GSD-managed Codex install emitted `event = "SessionStart"` — same target [[hooks]] schema, two different field names. Codex currently tolerates both via permissive parsing, but the moment one path tightens this becomes a silent #2760-class regression. Normalize both call sites on `event` (the existing GSD-managed convention). Update migration emit, docstring, and existing migration assertions to match. Add a parity regression test that drives both code paths and asserts the [[hooks]] field key is identical. * test(#2153): fix test isolation by building hooks/dist on demand The "Codex install copies hook file (#2153)" regression depends on hooks/dist/ being populated, but that directory is gitignored and only built by `npm run build:hooks`. The npm pretest chain runs `build:sdk` but not `build:hooks`, so when this file is run in isolation (`node --test tests/codex-config.test.cjs`) the hook copy step skips silently and the regression test fails on a stale-environment artifact rather than a real bug. Add a top-level before() hook that runs scripts/build-hooks.js when hooks/dist/ is missing or empty. Matches the pattern already used by bug-1834-sh-hooks-installed and other install integration tests, so the suite passes regardless of runner ordering or which tests are targeted. * fix(#2760): structural TOML validation, atomic writes, and behavioral test rewrites Addresses CodeRabbit review on PR #2785 plus source-grep violations the maintainer flagged in the regression test. Fix 1 (CR 3149606220) — validateCodexConfigSchema now parses the TOML into a structured object first via the new parseTomlToObject helper, then runs schema-shape checks against both the parsed structure and the table section headers. Malformed TOML with valid-looking headers no longer slips past validation. Fix 2 (CR 3149606224) — Replaced the four source-grep assertions in tests/bug-2760-codex-install-defensive.test.cjs (lines 109, 125, 169, 201) with structural assertions against the parsed TOML object via the exported parseTomlToObject helper. Tests now verify behavior (the file parses and contains the expected structure) instead of literal byte patterns. Robust to formatting changes — exactly what the regex-loosening suggestion was reaching for, done correctly. Confirmed clean by `npm run lint:tests` (0 violations). Fix 3 (CR 3149606234) — The describe block that mutates installModule.__codexSchemaValidator now runs with concurrency: false so the test seam mutation cannot leak into sibling suites that also call runCodexInstall. Fix 4 (CR outside-diff) — Approach (b): atomic temp-file + renameSync. Added atomicWriteFileSync helper used by mergeCodexConfig and the final hooks-write. A mid-write failure leaves the .tmp-<pid>-<n> sibling behind (cleaned up immediately) and never truncates the original config.toml. Paired with try/catch wrapping around the entire post-snapshot mutation sequence so any unexpected throw also triggers restoreCodexSnapshot. Two layers of defense: atomic write prevents the corruption window, snapshot restore handles non-atomic write paths. Added behavioral test for fix 4: stubs fs.renameSync to throw on the configPath rename, asserts the on-disk bytes match the pre-install snapshot byte-for-byte, asserts the parsed structure is still the user's [model] section (no half-written GSD agents block), and asserts no stray .tmp-* files remain. Marked concurrency: false because it monkey-patches a global. Test results: 5749/5749 pass, 0 fail. lint:tests clean. * test(#2760): TOML-parse based assertions for bare-agents purge and hook-field parity (CodeRabbit follow-up) * fix(#2760): treat write failures as fatal, strip legacy hooks before guard, tighten TOML parser (CR4) CR4 finding 1 (MAJOR) — Write failures silently succeeded. The inner catch around atomicWriteFileSync restored the snapshot then re-threw, but the outer catch only matched 'post-write Codex schema validation failed' and downgraded everything else to a warn-and-continue. Install finished with "Done!" while Codex had no GSD agents configured. Fix: wrap writeErr with a `post-write Codex install failed:` prefix and broaden the outer guard to `.startsWith( 'post-write')` so both schema-validation and write failures abort install. CR4 finding 2 (MAJOR) — Legacy flat [[hooks]] block prevented namespaced AoT upgrade. The `!configContent.includes('gsd-check-update')` guard short- circuited the new namespaced emit when an existing install had the legacy flat [[hooks]] block, leaving users stuck in the mixed layout this fix is designed to eliminate. Fix: strip ALL existing managed gsd-check-update hook blocks (top-level [[hooks]] AND namespaced [[hooks.SessionStart]]) BEFORE evaluating the includes guard, so every install converges on the right shape regardless of prior state. CR4 finding 3 (MAJOR) — Homegrown TOML parser silently accepted malformed input. parseTomlValue happily consumed the `0` prefix of `timeout = 0.5` and parseTomlToObject did not verify the full RHS was consumed, so `key = "x" junk` and date/time literals slipped through. Per CONTRIBUTING ("No external dependencies in core"), option (b) was chosen over adding @iarna/toml: (a) parseTomlValue rejects any integer immediately followed by `.`, `e`, `E`, `:`, `-`, `T`, or `Z` (floats / dates / times); (b) parseTomlToObject scans from parsed.end to the next newline and throws `trailing bytes after value` if anything other than whitespace + optional `# comment` is present. * test(#2760): add CR4 regression tests + scope GSD_TEST_MODE + rename rename-fault test CR4 finding 5 (NIT) — GSD_TEST_MODE leak. Saved previous value, set '1' for the require, then restored (delete if undefined). No more test-only env var leaking to siblings in the same node process. CR4 finding 4 (NIT) — Renamed the existing fix-4 test from 'fs.writeFileSync' to 'fs.renameSync' (the only call actually faulted) and added a sibling test that stubs fs.writeFileSync to throw on the .tmp- target — exercising the pre-rename branch of atomicWriteFileSync that was previously untested. Both serialize via concurrency: false on the existing describe block. CR4 finding 1 (MAJOR test) — New behavioral test asserts install throws with a `post-write Codex install failed` message AND never prints "Done!" when the hook-block atomic rename fails. Captures stdout via console.log stub, asserts byte equality of restored snapshot. Faults only the rename whose temp source contains gsd-check-update so earlier mergeCodexConfig writes are not collateral damage. CR4 finding 2 (MAJOR test) — New TOML-parsed behavioral test for the legacy-hook upgrade path: pre-install has [[hooks.SessionStart]] (user) + legacy flat [[hooks]] managed gsd-check-update entry; post-install must have hooks.SessionStart as Array-of-tables with both user hook and GSD entry, and no top-level [[hooks]] AoT remaining. Also asserts exactly one gsd-check-update entry (no duplicates). CR4 finding 3 (MAJOR test) — parseTomlToObject regression suite: rejects floats (timeout = 0.5), dates (created = 1979-05-27), trailing garbage (key = "x" junk), and accepts trailing whitespace + # comment. * fix(#2760): CR5 — pre-write fatal, TOML duplicate-key/header rejection, namespaced AoT migration Address all five CodeRabbit round-5 findings on PR #2785: Finding 1 (MAJOR) — Pre-write failures in the Codex hook configuration catch (around bin/install.js:7002) used to fall through to console.warn even though restoreCodexSnapshot() had already run. This produced "Done!" output with no Codex hooks configured. Now wraps the original error with a "(pre-write)" prefix and rethrows so install aborts loudly. Same defect class as CR4 finding 1, different layer. Finding 2 (MAJOR) — parseTomlToObject silently reused existing tables and overwrote duplicate keys. Real TOML 1.0 rejects: - duplicate scalar key in same table ([a]\nx=1\nx=2) - re-declared [a] header (two [a] sections) - [[arr]] then [arr] for same path (shape mismatch) Tracks pathShape, declaredHeaders, and per-table-instance key sets; throws "duplicate or shape-mismatched table header at <path>" or "duplicate key <name> in <path>". Finding 3 (MAJOR) — migrateCodexHooksMapFormat used to emit flat [[hooks]]\nevent="<TYPE>", which produced mixed flat+namespaced layouts when the user already had [[hooks.<OTHER>]] entries. Now emits [[hooks.<TYPE>]] directly (the namespace IS the event); managed-emit detector hasUserNamespacedAotHooks fires correctly so the install converges on a single namespaced layout regardless of pre-existing state. Finding 4 (NIT) — tests/bug-2760-codex-install-defensive.test.cjs rename-failure test tightened from "throw OR warn acceptable" to assert.equal(threw, true), locking the contract Finding 1 establishes. Finding 5 (NIT) — bug-2760 test suite snapshots and restores fs.renameSync defensively in beforeEach/afterEach (symmetric with fs.writeFileSync), removing the fragile per-test try/finally. Second test in the same suite cleaned up to drop its try/finally. Updates tests/codex-config.test.cjs to assert the new namespaced AoT migration shape via parseTomlToObject (no source-grep). Existing field- parity test reframed as shape-parity since both paths now emit namespaced. Tests: 5764 pass (+8 new). lint:tests: 0 violations. * docs(#2760): add CHANGELOG entry for Codex install defensive triple Adds the [Unreleased] Fixed entry for the Codex install fix landed in this PR — defensive strip of legacy [agents]/[[agents]] blocks, namespaced AoT hook detection across all events, atomic write + rollback, strict TOML validation rejecting duplicate keys/repeated headers/trailing bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
674
bin/install.js
674
bin/install.js
@@ -2089,6 +2089,10 @@ function generateCodexConfigBlock(agents, targetDir) {
|
||||
/**
|
||||
* Strip any managed GSD agent sections from a TOML string.
|
||||
*
|
||||
* Used by the uninstall path (`stripGsdFromCodexConfig`). Removes only what GSD
|
||||
* owns; user-authored `[agents.<name>]` and `[[agents]]` entries are preserved
|
||||
* so uninstall returns the file to its pre-GSD shape.
|
||||
*
|
||||
* Handles BOTH shapes so reinstall self-heals configs from all GSD versions:
|
||||
* - Current (#2727): `[agents.gsd-*]` struct tables (Codex 0.120.0+).
|
||||
* - Legacy (#2645): `[[agents]]` array-of-tables whose `name = "gsd-*"`.
|
||||
@@ -2815,25 +2819,32 @@ function isLegacyGsdAgentsSection(body) {
|
||||
}
|
||||
|
||||
function stripLeakedGsdCodexSections(content) {
|
||||
// Defensive precedence (#2760): we own the `agents` namespace under our
|
||||
// managed `gsd-*` names, and the legacy bare-table and sequence forms
|
||||
// (`[agents]`, `[[agents]]`) are invalid in the current Codex schema —
|
||||
// they trigger "invalid type: ..., expected struct AgentsToml" and break
|
||||
// every Codex CLI invocation. They MUST never coexist with the new
|
||||
// `[agents.<name>]` struct format we now emit, so install-time always
|
||||
// purges them regardless of GSD marker presence. Users who had legitimate
|
||||
// user-authored `[[agents]]` entries before are already broken on Codex
|
||||
// ≥0.124 — purging is the only path to a loadable config.
|
||||
const leakedSections = getTomlTableSections(content)
|
||||
.filter((section) => {
|
||||
// Legacy [agents.gsd-<name>] map tables (pre-#2645).
|
||||
if (!section.array && section.path.startsWith('agents.gsd-')) return true;
|
||||
|
||||
// Legacy bare [agents] table with only the old max_threads/max_depth keys.
|
||||
if (
|
||||
!section.array &&
|
||||
section.path === 'agents' &&
|
||||
isLegacyGsdAgentsSection(content.slice(section.headerEnd, section.end))
|
||||
) return true;
|
||||
// ANY bare [agents] single-bracket table — invalid in current Codex
|
||||
// schema, always purged at install time (#2760). Previously gated
|
||||
// on `isLegacyGsdAgentsSection`, which missed bare tables holding
|
||||
// arbitrary user keys (`default = "..."`, etc.) that still produce
|
||||
// the AgentsToml type error.
|
||||
if (!section.array && section.path === 'agents') return true;
|
||||
|
||||
// Current [[agents]] array-of-tables whose name is gsd-*. Preserve
|
||||
// user-authored [[agents]] entries (other names) untouched.
|
||||
if (section.array && section.path === 'agents') {
|
||||
const body = content.slice(section.headerEnd, section.end);
|
||||
const nameMatch = body.match(/^[ \t]*name[ \t]*=[ \t]*["']([^"']+)["']/m);
|
||||
if (nameMatch && /^gsd-/.test(nameMatch[1])) return true;
|
||||
}
|
||||
// ANY [[agents]] array-of-tables — invalid in current Codex schema,
|
||||
// always purged at install time (#2760). Previously gated on
|
||||
// `name = "gsd-..."` which preserved user-authored entries that are
|
||||
// themselves rejected by Codex 0.124+.
|
||||
if (section.array && section.path === 'agents') return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
@@ -2862,15 +2873,17 @@ function stripLeakedGsdCodexSections(content) {
|
||||
* [hooks.shell]
|
||||
* command = "..."
|
||||
*
|
||||
* to the new array-of-tables format:
|
||||
* [[hooks]]
|
||||
* type = "shell"
|
||||
* to the new array-of-tables format. #2760 CR5 finding 3 — emit the
|
||||
* namespaced AoT shape directly so a mixed flat + namespaced layout never
|
||||
* arises post-install:
|
||||
* [[hooks.shell]]
|
||||
* command = "..."
|
||||
*
|
||||
* This function detects any non-array hooks sections in the config and
|
||||
* converts them to the [[hooks]] format, preserving all key-value pairs and
|
||||
* user comments. Bare [hooks] container sections (no key-value content) are
|
||||
* dropped. User-authored [[hooks]] array entries are left untouched.
|
||||
* converts them to the namespaced `[[hooks.<TYPE>]]` array-of-tables form,
|
||||
* preserving all key-value pairs and user comments. Bare [hooks] container
|
||||
* sections (no key-value content) are dropped. User-authored AoT entries are
|
||||
* left untouched.
|
||||
*
|
||||
* Returns the migrated content, or the original content unchanged if no
|
||||
* legacy hooks sections were found.
|
||||
@@ -2901,8 +2914,15 @@ function migrateCodexHooksMapFormat(content) {
|
||||
const type = section.path.slice('hooks.'.length);
|
||||
const body = content.slice(section.headerEnd, section.end);
|
||||
|
||||
// Build [[hooks]] block: type line + original body lines
|
||||
const block = `[[hooks]]${eol}type = "${type}"${eol}${body}`;
|
||||
// #2760 CR5 finding 3 — emit the namespaced AoT form directly:
|
||||
// `[[hooks.<TYPE>]]` (no synthetic `event` field — the namespace IS the
|
||||
// event). Previously we emitted flat `[[hooks]]\nevent = "<TYPE>"`,
|
||||
// which produced mixed flat + namespaced layouts when the user already
|
||||
// had `[[hooks.<OTHER>]]` entries. With every migration emit using the
|
||||
// namespaced shape, the managed-emit detector
|
||||
// (`hasUserNamespacedAotHooks`) fires correctly and the install
|
||||
// converges on a single hook layout.
|
||||
const block = `[[hooks.${type}]]${eol}${body}`;
|
||||
newHooksBlocks.push(block);
|
||||
}
|
||||
|
||||
@@ -2955,6 +2975,446 @@ function migrateCodexHooksMapFormat(content) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the user already uses the namespaced AoT hooks form
|
||||
* (`[[hooks.<EVENT>]]`) for the given event in the config. When true,
|
||||
* the GSD-managed hook block must be emitted in the same shape so it
|
||||
* coexists cleanly — mixing `[[hooks]]` (flat) with `[[hooks.SessionStart]]`
|
||||
* (namespaced) in the same file confuses round-trip writers and can
|
||||
* produce a config that Codex rejects (#2760, defect 3).
|
||||
*/
|
||||
function hasUserNamespacedAotHooks(content, event) {
|
||||
const sections = getTomlTableSections(content);
|
||||
return sections.some(
|
||||
(section) => section.array && section.path === `hooks.${event}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a TOML value RHS expression starting at index `i` of `text`.
|
||||
* Returns { value, end } on success or throws on parse failure.
|
||||
*
|
||||
* Supports the value forms GSD emits or that real Codex configs commonly use:
|
||||
* - basic strings ("…" with simple escapes)
|
||||
* - literal strings ('…')
|
||||
* - booleans (true / false)
|
||||
* - integers (optional sign, decimal digits)
|
||||
* - inline arrays of the above
|
||||
* - inline tables { k = v, … }
|
||||
*
|
||||
* This is intentionally not a complete TOML implementation — it is the
|
||||
* minimal value grammar required to validate Codex config structure and to
|
||||
* back behavioral assertions in tests (#2760).
|
||||
*/
|
||||
function parseTomlValue(text, i) {
|
||||
// Skip leading whitespace.
|
||||
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
|
||||
i += 1;
|
||||
}
|
||||
if (i >= text.length) {
|
||||
throw new Error('expected value, got end of input');
|
||||
}
|
||||
|
||||
const ch = text[i];
|
||||
|
||||
// Basic string
|
||||
if (ch === '"') {
|
||||
if (text.startsWith('"""', i)) {
|
||||
const close = findMultilineBasicStringClose(text, i + 3);
|
||||
if (close === -1) {
|
||||
throw new Error('unterminated multi-line basic string');
|
||||
}
|
||||
const raw = text.slice(i + 3, close);
|
||||
return { value: raw.replace(/^\r?\n/, ''), end: close + 3 };
|
||||
}
|
||||
let j = i + 1;
|
||||
let out = '';
|
||||
while (j < text.length) {
|
||||
const c = text[j];
|
||||
if (c === '\\') {
|
||||
const next = text[j + 1];
|
||||
if (next === 'n') { out += '\n'; j += 2; continue; }
|
||||
if (next === 't') { out += '\t'; j += 2; continue; }
|
||||
if (next === 'r') { out += '\r'; j += 2; continue; }
|
||||
if (next === '\\') { out += '\\'; j += 2; continue; }
|
||||
if (next === '"') { out += '"'; j += 2; continue; }
|
||||
if (next === '/') { out += '/'; j += 2; continue; }
|
||||
// Pass-through unrecognized escape (Codex/GSD don't use these).
|
||||
out += next === undefined ? '' : next;
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
return { value: out, end: j + 1 };
|
||||
}
|
||||
out += c;
|
||||
j += 1;
|
||||
}
|
||||
throw new Error('unterminated basic string');
|
||||
}
|
||||
|
||||
// Literal string
|
||||
if (ch === '\'') {
|
||||
if (text.startsWith('\'\'\'', i)) {
|
||||
const close = text.indexOf('\'\'\'', i + 3);
|
||||
if (close === -1) throw new Error('unterminated multi-line literal string');
|
||||
return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 };
|
||||
}
|
||||
const close = text.indexOf('\'', i + 1);
|
||||
if (close === -1) throw new Error('unterminated literal string');
|
||||
return { value: text.slice(i + 1, close), end: close + 1 };
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (text.startsWith('true', i) && !/[A-Za-z0-9_-]/.test(text[i + 4] || '')) {
|
||||
return { value: true, end: i + 4 };
|
||||
}
|
||||
if (text.startsWith('false', i) && !/[A-Za-z0-9_-]/.test(text[i + 5] || '')) {
|
||||
return { value: false, end: i + 5 };
|
||||
}
|
||||
|
||||
// Inline array
|
||||
if (ch === '[') {
|
||||
const arr = [];
|
||||
let j = i + 1;
|
||||
while (true) {
|
||||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||||
if (j >= text.length) throw new Error('unterminated inline array');
|
||||
if (text[j] === ']') return { value: arr, end: j + 1 };
|
||||
if (text[j] === '#') {
|
||||
const nl = text.indexOf('\n', j);
|
||||
j = nl === -1 ? text.length : nl + 1;
|
||||
continue;
|
||||
}
|
||||
const parsed = parseTomlValue(text, j);
|
||||
arr.push(parsed.value);
|
||||
j = parsed.end;
|
||||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||||
if (j < text.length && text[j] === ',') {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||||
if (text[j] === ']') return { value: arr, end: j + 1 };
|
||||
throw new Error(`expected , or ] in inline array at offset ${j}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline table
|
||||
if (ch === '{') {
|
||||
const obj = {};
|
||||
let j = i + 1;
|
||||
while (true) {
|
||||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||||
if (text[j] === '}') return { value: obj, end: j + 1 };
|
||||
const keyMatch = text.slice(j).match(/^([A-Za-z0-9_-]+|"[^"]*"|'[^']*')\s*=\s*/);
|
||||
if (!keyMatch) throw new Error(`expected key in inline table at offset ${j}`);
|
||||
let rawKey = keyMatch[1];
|
||||
if ((rawKey.startsWith('"') && rawKey.endsWith('"')) || (rawKey.startsWith('\'') && rawKey.endsWith('\''))) {
|
||||
rawKey = rawKey.slice(1, -1);
|
||||
}
|
||||
j += keyMatch[0].length;
|
||||
const parsed = parseTomlValue(text, j);
|
||||
obj[rawKey] = parsed.value;
|
||||
j = parsed.end;
|
||||
while (j < text.length && /[\s\r\n]/.test(text[j])) j += 1;
|
||||
if (text[j] === ',') { j += 1; continue; }
|
||||
if (text[j] === '}') return { value: obj, end: j + 1 };
|
||||
throw new Error(`expected , or } in inline table at offset ${j}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Number (integer with optional sign). Float / date / time / hex / oct / bin
|
||||
// are NOT supported — we reject them explicitly instead of silently truncating
|
||||
// an integer prefix off a `0.5` float or `1979-05-27` date. (#2760 CR4 finding 3)
|
||||
const numMatch = text.slice(i).match(/^[+-]?\d[\d_]*/);
|
||||
if (numMatch) {
|
||||
const after = text[i + numMatch[0].length];
|
||||
// Reject: float (`.`, `e`, `E`), date/time (`-`, `:`, `T`, `Z`), or any
|
||||
// continuation digit/letter that suggests an unsupported numeric form.
|
||||
if (after !== undefined && /[.eE:\-TZ]/.test(after)) {
|
||||
throw new Error(
|
||||
`unsupported TOML value at offset ${i}: floats, dates, and times are not supported (got ${text.slice(i, i + 20)})`
|
||||
);
|
||||
}
|
||||
const digits = numMatch[0].replace(/_/g, '');
|
||||
const n = Number(digits);
|
||||
if (!Number.isFinite(n)) throw new Error(`invalid number: ${numMatch[0]}`);
|
||||
return { value: n, end: i + numMatch[0].length };
|
||||
}
|
||||
|
||||
throw new Error(`unsupported value at offset ${i}: ${text.slice(i, i + 20)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TOML content into a JavaScript object. Throws on malformed input.
|
||||
*
|
||||
* Handles `[table]`, `[[array.of.tables]]`, dotted key paths, and the value
|
||||
* forms supported by parseTomlValue. Sufficient for validating Codex config
|
||||
* structure and for behavioral test assertions in #2760 — not a general
|
||||
* TOML implementation.
|
||||
*/
|
||||
function parseTomlToObject(content) {
|
||||
const root = {};
|
||||
const records = getTomlLineRecords(content);
|
||||
// Tracks the *object* (not path) that subsequent key=value lines target.
|
||||
let currentTable = root;
|
||||
|
||||
// #2760 CR5 finding 2 — track shape and definition status of every path so
|
||||
// we can reject duplicate header redeclarations, shape mismatches, and
|
||||
// duplicate keys per real TOML 1.0 semantics. Without this, walkPath
|
||||
// silently reuses existing tables and assignment overwrites existing keys —
|
||||
// a real TOML parser would refuse the file.
|
||||
//
|
||||
// pathShape: dotted path -> 'table' | 'array' | 'inline_parent' | 'key'
|
||||
// - 'table' — declared via [a.b]
|
||||
// - 'array' — declared via [[a.b]] (path is the array itself; each
|
||||
// element is its own implicit table)
|
||||
// - 'inline_parent' — created implicitly while walking parents
|
||||
// - 'key' — assigned a scalar value
|
||||
// declaredHeaders: set of dotted paths explicitly declared via [hdr] (not
|
||||
// [[arr]]) — used to reject duplicate [a] / [a] sections.
|
||||
// tableKeys: dotted-path -> Set<string> of keys assigned in that exact
|
||||
// table instance. For [[arr]] elements we use a per-element marker.
|
||||
const pathShape = new Map();
|
||||
const declaredHeaders = new Set();
|
||||
const tableKeys = new Map();
|
||||
// currentTableId — string identifier for the current table instance, used
|
||||
// as the key into tableKeys so that key uniqueness is per-table-instance
|
||||
// (each [[arr]] element gets its own id).
|
||||
let currentTableId = '__root__';
|
||||
pathShape.set('__root__', 'table');
|
||||
tableKeys.set('__root__', new Set());
|
||||
|
||||
function ensureKeySet(id) {
|
||||
if (!tableKeys.has(id)) tableKeys.set(id, new Set());
|
||||
return tableKeys.get(id);
|
||||
}
|
||||
|
||||
function walkPath(segments, { creatingArrayElement = false } = {}) {
|
||||
let node = root;
|
||||
const parents = segments.slice(0, -1);
|
||||
const last = segments[segments.length - 1];
|
||||
|
||||
for (let p = 0; p < parents.length; p += 1) {
|
||||
const seg = parents[p];
|
||||
const partialPath = parents.slice(0, p + 1).join('.');
|
||||
if (node[seg] === undefined) {
|
||||
node[seg] = {};
|
||||
if (!pathShape.has(partialPath)) {
|
||||
pathShape.set(partialPath, 'inline_parent');
|
||||
}
|
||||
} else if (Array.isArray(node[seg])) {
|
||||
// Walk into the latest element of an array-of-tables.
|
||||
node = node[seg][node[seg].length - 1];
|
||||
continue;
|
||||
} else if (typeof node[seg] !== 'object' || node[seg] === null) {
|
||||
throw new Error(`path segment ${seg} is not a table`);
|
||||
}
|
||||
node = node[seg];
|
||||
}
|
||||
|
||||
const fullPath = segments.join('.');
|
||||
|
||||
if (creatingArrayElement) {
|
||||
const existingShape = pathShape.get(fullPath);
|
||||
if (node[last] === undefined) {
|
||||
node[last] = [];
|
||||
pathShape.set(fullPath, 'array');
|
||||
} else if (!Array.isArray(node[last])) {
|
||||
throw new Error(
|
||||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||||
`cannot redefine as array of tables (previously seen as ${existingShape || 'table'})`
|
||||
);
|
||||
} else if (existingShape && existingShape !== 'array') {
|
||||
throw new Error(
|
||||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||||
`previously seen as ${existingShape}, cannot extend as array of tables`
|
||||
);
|
||||
}
|
||||
const elem = {};
|
||||
node[last].push(elem);
|
||||
const elemId = `${fullPath}[${node[last].length - 1}]`;
|
||||
pathShape.set(elemId, 'array_element');
|
||||
tableKeys.set(elemId, new Set());
|
||||
currentTableId = elemId;
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Plain [table] header.
|
||||
if (node[last] === undefined) {
|
||||
node[last] = {};
|
||||
pathShape.set(fullPath, 'table');
|
||||
declaredHeaders.add(fullPath);
|
||||
tableKeys.set(fullPath, new Set());
|
||||
} else if (Array.isArray(node[last])) {
|
||||
throw new Error(
|
||||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||||
`previously declared as array of tables ([[${fullPath}]]), cannot redeclare as table ([${fullPath}])`
|
||||
);
|
||||
} else if (typeof node[last] !== 'object') {
|
||||
throw new Error(`cannot redefine ${fullPath} as table`);
|
||||
} else if (declaredHeaders.has(fullPath)) {
|
||||
throw new Error(
|
||||
`duplicate or shape-mismatched table header at ${fullPath}: ` +
|
||||
`[${fullPath}] declared more than once`
|
||||
);
|
||||
} else {
|
||||
// Implicitly created earlier (e.g., as a parent path); first explicit
|
||||
// declaration is allowed.
|
||||
pathShape.set(fullPath, 'table');
|
||||
declaredHeaders.add(fullPath);
|
||||
if (!tableKeys.has(fullPath)) tableKeys.set(fullPath, new Set());
|
||||
}
|
||||
currentTableId = fullPath;
|
||||
return node[last];
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < records.length; idx += 1) {
|
||||
const rec = records[idx];
|
||||
if (rec.startsInMultilineString) continue;
|
||||
if (rec.tableHeader) {
|
||||
const segs = rec.tableHeader.segments;
|
||||
currentTable = walkPath(segs, { creatingArrayElement: rec.tableHeader.array });
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = rec.text.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||
|
||||
const equalsIndex = findTomlAssignmentEquals(rec.text);
|
||||
if (equalsIndex === -1) continue;
|
||||
|
||||
const keyText = rec.text.slice(0, equalsIndex).trim();
|
||||
const segments = parseTomlKeyPath(keyText);
|
||||
if (!segments) {
|
||||
throw new Error(`invalid TOML key on line ${idx + 1}: ${rec.text}`);
|
||||
}
|
||||
|
||||
// Value RHS may span multiple lines (inline arrays, multi-line strings,
|
||||
// inline tables). Parse from the absolute content offset right after `=`.
|
||||
const valueStartAbs = rec.start + equalsIndex + 1;
|
||||
const parsed = parseTomlValue(content, valueStartAbs);
|
||||
|
||||
// #2760 CR4 finding 3 — verify the full RHS was consumed. Anything other
|
||||
// than whitespace + optional # comment between parsed.end and the next
|
||||
// newline (or EOF) means the parser silently accepted a prefix and
|
||||
// dropped trailing bytes. Reject so malformed TOML cannot slip past
|
||||
// "parse before commit" guarantees.
|
||||
let scan = parsed.end;
|
||||
while (scan < content.length && (content[scan] === ' ' || content[scan] === '\t')) {
|
||||
scan += 1;
|
||||
}
|
||||
if (scan < content.length && content[scan] !== '\n' && content[scan] !== '\r' && content[scan] !== '#') {
|
||||
const lineEnd = content.indexOf('\n', scan);
|
||||
const trailing = content.slice(scan, lineEnd === -1 ? content.length : lineEnd);
|
||||
throw new Error(
|
||||
`trailing bytes after value on line ${idx + 1}: ${JSON.stringify(trailing)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Place value into currentTable under dotted key.
|
||||
// #2760 CR5 finding 2 — reject duplicate keys per real TOML 1.0. Track
|
||||
// the dotted key against the current table instance id; an exact repeat
|
||||
// throws.
|
||||
let target = currentTable;
|
||||
for (let s = 0; s < segments.length - 1; s += 1) {
|
||||
const seg = segments[s];
|
||||
if (target[seg] === undefined) target[seg] = {};
|
||||
else if (typeof target[seg] !== 'object' || Array.isArray(target[seg])) {
|
||||
throw new Error(`cannot descend into non-table key ${seg}`);
|
||||
}
|
||||
target = target[seg];
|
||||
}
|
||||
const finalKey = segments[segments.length - 1];
|
||||
const dottedKey = segments.join('.');
|
||||
const keySet = ensureKeySet(currentTableId);
|
||||
if (keySet.has(dottedKey) || Object.prototype.hasOwnProperty.call(target, finalKey)) {
|
||||
throw new Error(
|
||||
`duplicate key ${dottedKey} in ${currentTableId === '__root__' ? 'root table' : currentTableId}`
|
||||
);
|
||||
}
|
||||
keySet.add(dottedKey);
|
||||
target[finalKey] = parsed.value;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the post-install config.toml matches Codex's expected schema
|
||||
* (#2760, fix 3). Returns { ok: true } on success, or { ok: false, reason }
|
||||
* with a human-readable explanation of the offending section.
|
||||
*
|
||||
* Strategy: parse the bytes into a structured object first — malformed TOML
|
||||
* fails validation immediately rather than slipping past a header-only scan.
|
||||
* Then enforce the schema-shape rules against the parsed structure.
|
||||
*
|
||||
* Schema rules enforced:
|
||||
* - File MUST parse as TOML (no syntax errors).
|
||||
* - `agents` MUST be a struct table (`[agents.<name>]`) — never a bare
|
||||
* table value or an array of tables.
|
||||
* - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
|
||||
* rejects bare `[hooks.<Event>]` single-bracket maps).
|
||||
*/
|
||||
function validateCodexConfigSchema(content) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseTomlToObject(content);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `TOML parse failed: ${e.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Header-shape check: arrays-of-tables are visible in the parsed structure
|
||||
// (as Array values) but bare-vs-struct distinction for `[agents]` requires
|
||||
// looking at section headers too — `[agents]` with `default = "x"` parses
|
||||
// to `{ agents: { default: 'x' } }`, indistinguishable from
|
||||
// `[agents.foo]` writing into the same shape. Use header sections to
|
||||
// disambiguate.
|
||||
const sections = getTomlTableSections(content);
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.array && section.path === 'agents') {
|
||||
return {
|
||||
ok: false,
|
||||
reason: '[[agents]] sequence form is invalid in current Codex schema (expected [agents.<name>] struct form)',
|
||||
};
|
||||
}
|
||||
|
||||
if (!section.array && section.path === 'agents') {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.<name>] struct form)',
|
||||
};
|
||||
}
|
||||
|
||||
if (!section.array && section.path.startsWith('hooks.')) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `bare [${section.path}] table is invalid in current Codex schema (expected [[${section.path}]] array-of-tables)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Structural confirmation against parsed object: any present hooks.<Event>
|
||||
// must be an array.
|
||||
if (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
|
||||
for (const [event, value] of Object.entries(parsed.hooks)) {
|
||||
if (!Array.isArray(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `hooks.${event} must be an array of tables, got ${typeof value}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function normalizeCodexHooksLine(line, key) {
|
||||
const leadingWhitespace = line.match(/^\s*/)[0];
|
||||
const commentStart = findTomlCommentStart(line);
|
||||
@@ -3092,14 +3552,38 @@ function rewriteTomlKeyLines(content, matches, key) {
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write — write to <target>.tmp-<pid>-<n> first, then renameSync over
|
||||
* the target. Eliminates the partial-write corruption window: an interrupted
|
||||
* write leaves the temp file (which we clean up) but never truncates the
|
||||
* original target. Used for any mutation of Codex config.toml so we cannot
|
||||
* leave the user with a half-written file (#2760 fix 4).
|
||||
*/
|
||||
let __atomicWriteCounter = 0;
|
||||
function atomicWriteFileSync(target, data, options) {
|
||||
__atomicWriteCounter += 1;
|
||||
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, data, options);
|
||||
fs.renameSync(tmp, target);
|
||||
} catch (e) {
|
||||
// Best-effort cleanup of the partial temp file; never mask the real error.
|
||||
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge GSD config block into an existing or new config.toml.
|
||||
* Three cases: new file, existing with GSD marker, existing without marker.
|
||||
*
|
||||
* All writes go through atomicWriteFileSync so a mid-write failure leaves
|
||||
* the original config.toml untouched (#2760 fix 4).
|
||||
*/
|
||||
function mergeCodexConfig(configPath, gsdBlock) {
|
||||
// Case 1: No config.toml — create fresh
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(configPath, gsdBlock + '\n');
|
||||
atomicWriteFileSync(configPath, gsdBlock + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3115,9 +3599,9 @@ function mergeCodexConfig(configPath, gsdBlock) {
|
||||
// Strip any GSD-managed sections that leaked above the marker from previous installs
|
||||
before = stripLeakedGsdCodexSections(before).trimEnd();
|
||||
|
||||
fs.writeFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
|
||||
atomicWriteFileSync(configPath, before + eol + eol + normalizedGsdBlock + eol);
|
||||
} else {
|
||||
fs.writeFileSync(configPath, normalizedGsdBlock + eol);
|
||||
atomicWriteFileSync(configPath, normalizedGsdBlock + eol);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3130,7 +3614,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
|
||||
content = normalizedGsdBlock + eol;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content);
|
||||
atomicWriteFileSync(configPath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6412,9 +6896,36 @@ function install(isGlobal, runtime = 'claude') {
|
||||
}
|
||||
|
||||
if (isCodex && !isMinimalMode(installMode)) {
|
||||
// Generate Codex config.toml and per-agent .toml files.
|
||||
// Skipped under --minimal — same rationale as filesystem agents above.
|
||||
const agentCount = installCodexConfig(targetDir, agentsSrc);
|
||||
// Capture pre-install snapshot of config.toml before ANY GSD mutation
|
||||
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
||||
// during the mutation sequence (write failure, merge throw, etc.) we
|
||||
// restore these exact bytes so the user is never left with a broken
|
||||
// Codex CLI (#2760 fix 4 — extends snapshot coverage to write-failure
|
||||
// paths, paired with atomic temp-file writes in mergeCodexConfig and
|
||||
// the final hooks-write below).
|
||||
const codexConfigPathPreInstall = path.join(targetDir, 'config.toml');
|
||||
const codexConfigPreInstallSnapshot = fs.existsSync(codexConfigPathPreInstall)
|
||||
? fs.readFileSync(codexConfigPathPreInstall)
|
||||
: null;
|
||||
|
||||
const restoreCodexSnapshot = () => {
|
||||
if (codexConfigPreInstallSnapshot !== null) {
|
||||
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
|
||||
catch (_) { /* best-effort restore — surface the original error */ }
|
||||
} else if (fs.existsSync(codexConfigPathPreInstall)) {
|
||||
try { fs.rmSync(codexConfigPathPreInstall); } catch (_) { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
|
||||
let agentCount;
|
||||
try {
|
||||
// Generate Codex config.toml and per-agent .toml files.
|
||||
// Skipped under --minimal — same rationale as filesystem agents above.
|
||||
agentCount = installCodexConfig(targetDir, agentsSrc);
|
||||
} catch (e) {
|
||||
restoreCodexSnapshot();
|
||||
throw e;
|
||||
}
|
||||
console.log(` ${green}✓${reset} Generated config.toml with ${agentCount} agent roles`);
|
||||
console.log(` ${green}✓${reset} Generated ${agentCount} agent .toml config files`);
|
||||
|
||||
@@ -6454,6 +6965,10 @@ function install(isGlobal, runtime = 'claude') {
|
||||
|
||||
// Add Codex hooks (SessionStart for update checking) — requires codex_hooks feature flag
|
||||
const configPath = path.join(targetDir, 'config.toml');
|
||||
// Use the pre-install snapshot captured before installCodexConfig ran so
|
||||
// restore returns the file to its true pre-GSD state on validation
|
||||
// failure (#2760 fix 3) — not to the post-agent-merge state.
|
||||
const preWriteBackup = codexConfigPreInstallSnapshot;
|
||||
try {
|
||||
let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
|
||||
const eol = detectLineEnding(configContent);
|
||||
@@ -6470,13 +6985,22 @@ function install(isGlobal, runtime = 'claude') {
|
||||
const codexHooksFeature = ensureCodexHooksFeature(configContent);
|
||||
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
|
||||
|
||||
// Add SessionStart hook for update checking
|
||||
// Add SessionStart hook for update checking. Default to top-level
|
||||
// `[[hooks]]` AoT with `event` field — the form GSD has emitted since
|
||||
// the Codex 0.124 migration (#2637). When the user already uses the
|
||||
// namespaced AoT form `[[hooks.SessionStart]]` for their own hooks,
|
||||
// emit our managed entry in that same shape so the two forms don't
|
||||
// collide on round-trip (#2760, defect 3).
|
||||
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||||
const hookBlock =
|
||||
`${eol}# GSD Hooks${eol}` +
|
||||
`[[hooks]]${eol}` +
|
||||
`event = "SessionStart"${eol}` +
|
||||
`command = "node ${updateCheckScript}"${eol}`;
|
||||
const useNamespacedAot = hasUserNamespacedAotHooks(configContent, 'SessionStart');
|
||||
const hookBlock = useNamespacedAot
|
||||
? `${eol}# GSD Hooks${eol}` +
|
||||
`[[hooks.SessionStart]]${eol}` +
|
||||
`command = "node ${updateCheckScript}"${eol}`
|
||||
: `${eol}# GSD Hooks${eol}` +
|
||||
`[[hooks]]${eol}` +
|
||||
`event = "SessionStart"${eol}` +
|
||||
`command = "node ${updateCheckScript}"${eol}`;
|
||||
|
||||
// Migrate legacy gsd-update-check entries from prior installs (#1755 followup)
|
||||
// Remove stale hook blocks that used the inverted filename or wrong path.
|
||||
@@ -6488,14 +7012,89 @@ function install(isGlobal, runtime = 'claude') {
|
||||
);
|
||||
}
|
||||
|
||||
// #2760 CR4 finding 2 — Strip ALL existing managed gsd-check-update
|
||||
// hook blocks (top-level [[hooks]] AND namespaced [[hooks.SessionStart]])
|
||||
// BEFORE evaluating the includes guard. Without this, an install that
|
||||
// already has a legacy flat [[hooks]] block short-circuits the new
|
||||
// namespaced AoT emit and stays stuck in the mixed layout this fix is
|
||||
// designed to eliminate. Stripping first means every install converges
|
||||
// on the right shape regardless of prior state.
|
||||
if (configContent.includes('gsd-check-update')) {
|
||||
configContent = configContent.replace(
|
||||
/(?:\r?\n|^)# GSD Hooks\r?\n\[\[hooks\]\]\r?\nevent = "SessionStart"\r?\ncommand = "node [^\r\n]*gsd-check-update\.js"\r?\n/gm,
|
||||
(match) => (match.startsWith('\r\n') ? '\r\n' : match.startsWith('\n') ? '\n' : ''),
|
||||
);
|
||||
configContent = configContent.replace(
|
||||
/(?:\r?\n|^)# GSD Hooks\r?\n\[\[hooks\.SessionStart\]\]\r?\ncommand = "node [^\r\n]*gsd-check-update\.js"\r?\n/gm,
|
||||
(match) => (match.startsWith('\r\n') ? '\r\n' : match.startsWith('\n') ? '\n' : ''),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasEnabledCodexHooksFeature(configContent) && !configContent.includes('gsd-check-update')) {
|
||||
configContent += hookBlock;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, configContent, 'utf-8');
|
||||
// #2760 fix 3 — post-write schema validation. Parse the bytes we are
|
||||
// about to commit and assert they match Codex's expected shape. If
|
||||
// validation fails we restore the pre-install backup and abort so the
|
||||
// user is never left with a Codex CLI that won't load.
|
||||
// Test seam: tests can inject `__codexSchemaValidator` to force the
|
||||
// validator to fail and exercise the restore-and-abort path.
|
||||
const validatorFn = (typeof module !== 'undefined' && module.exports && module.exports.__codexSchemaValidator)
|
||||
? module.exports.__codexSchemaValidator
|
||||
: validateCodexConfigSchema;
|
||||
const validation = validatorFn(configContent);
|
||||
if (!validation.ok) {
|
||||
restoreCodexSnapshot();
|
||||
throw new Error(
|
||||
`post-write Codex schema validation failed: ${validation.reason}. ` +
|
||||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic write (#2760 fix 4) — write to a sibling temp file, then
|
||||
// renameSync over the target. A mid-write failure cannot truncate the
|
||||
// existing config; the snapshot restore below is a second line of
|
||||
// defense if even the rename fails.
|
||||
try {
|
||||
atomicWriteFileSync(configPath, configContent, 'utf-8');
|
||||
} catch (writeErr) {
|
||||
// #2760 CR4 finding 1 — write failure must be loud and fatal. Wrap
|
||||
// with a `post-write` prefix the outer catch recognises so install
|
||||
// aborts with a clear error rather than warn-and-continue (which
|
||||
// produced "Done!" with no Codex agents configured).
|
||||
restoreCodexSnapshot();
|
||||
const wrapped = new Error(
|
||||
`post-write Codex install failed: ${writeErr && writeErr.message ? writeErr.message : String(writeErr)}. ` +
|
||||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||||
);
|
||||
throw wrapped;
|
||||
}
|
||||
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`);
|
||||
} catch (e) {
|
||||
console.warn(` ${yellow}⚠${reset} Could not configure Codex hooks: ${e.message}`);
|
||||
// #2760 — schema-validation and write failures must be loud and fatal
|
||||
// so the user is never left with a config Codex refuses to load (or no
|
||||
// Codex agents configured at all). The pre-install snapshot restore has
|
||||
// already run for write-side throws via the inner catch above and via
|
||||
// restoreCodexSnapshot in the validation branch.
|
||||
if (e && typeof e.message === 'string' && e.message.startsWith('post-write')) {
|
||||
console.error(` ${red}✗${reset} ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
// #2760 CR5 finding 1 — pre-write failures (migrateCodexHooksMapFormat,
|
||||
// ensureCodexHooksFeature, config reads, configContent construction,
|
||||
// etc.) must ALSO be fatal. Previously this branch downgraded to a
|
||||
// console.warn, leaving the install to print "Done!" with no Codex
|
||||
// hooks configured — same defect class as finding 1, different layer.
|
||||
// Restore the pre-install snapshot and rethrow so the outer install
|
||||
// pipeline aborts.
|
||||
restoreCodexSnapshot();
|
||||
const wrapped = new Error(
|
||||
`Codex hook configuration failed (pre-write): ${e && e.message ? e.message : String(e)}. ` +
|
||||
`Restored ${preWriteBackup !== null ? 'pre-install backup' : 'empty state'}.`
|
||||
);
|
||||
console.error(` ${red}✗${reset} ${wrapped.message}`);
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
|
||||
@@ -7638,6 +8237,9 @@ if (process.env.GSD_TEST_MODE) {
|
||||
generateCodexConfigBlock,
|
||||
stripGsdFromCodexConfig,
|
||||
migrateCodexHooksMapFormat,
|
||||
hasUserNamespacedAotHooks,
|
||||
parseTomlToObject,
|
||||
validateCodexConfigSchema,
|
||||
mergeCodexConfig,
|
||||
installCodexConfig,
|
||||
readGsdRuntimeProfileResolver,
|
||||
|
||||
Reference in New Issue
Block a user