* 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>
979 lines
36 KiB
JavaScript
979 lines
36 KiB
JavaScript
/**
|
|
* Regression: issue #2760 — Codex install path corrupts existing config.toml.
|
|
*
|
|
* Three defects, three fixes (defensive triple):
|
|
*
|
|
* Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has
|
|
* `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD
|
|
* used to append a `[[hooks]]` (top-level AoT) block that confuses
|
|
* round-trip writers and produces a config Codex refuses to load.
|
|
* Fix: detect the user's preferred shape and emit GSD's hook in the same
|
|
* namespaced form so both coexist cleanly.
|
|
*
|
|
* Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy
|
|
* `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are
|
|
* invalid in current Codex schema and break Codex even though GSD now
|
|
* emits the correct `[agents.<name>]` struct form. Fix: install-time
|
|
* stripping always purges these forms regardless of GSD marker presence
|
|
* so reinstall self-heals files where the marker was edited out or never
|
|
* existed (third-party tools).
|
|
*
|
|
* Fix 3 (defensive) — Post-write validation. Parse the bytes we are about
|
|
* to commit, assert they match Codex's expected schema (no bare/sequence
|
|
* `agents`, no bare `hooks.<Event>`); on failure, restore the pre-install
|
|
* backup and abort so the user never gets a broken Codex CLI.
|
|
*/
|
|
|
|
// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so
|
|
// downstream tests in the same node process never see test-only behaviour
|
|
// leak through (#2760 CR4 finding 5).
|
|
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
const {
|
|
install,
|
|
validateCodexConfigSchema,
|
|
hasUserNamespacedAotHooks,
|
|
stripGsdFromCodexConfig,
|
|
installCodexConfig,
|
|
parseTomlToObject,
|
|
} = require('../bin/install.js');
|
|
|
|
if (previousGsdTestMode === undefined) {
|
|
delete process.env.GSD_TEST_MODE;
|
|
} else {
|
|
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
|
}
|
|
|
|
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
|
const previousCodeHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
process.env.CODEX_HOME = codexHome;
|
|
try {
|
|
process.chdir(cwd);
|
|
return install(true, 'codex');
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousCodeHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = previousCodeHome;
|
|
}
|
|
}
|
|
}
|
|
|
|
function readCodexConfig(codexHome) {
|
|
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
}
|
|
|
|
function writeCodexConfig(codexHome, content) {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
|
}
|
|
|
|
describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('preserves both pre-existing [[hooks.SessionStart]] entries and adds GSD entry in namespaced form', () => {
|
|
const userConfig = [
|
|
'[[hooks.SessionStart]]',
|
|
'command = "echo first user hook"',
|
|
'',
|
|
'[[hooks.SessionStart]]',
|
|
'command = "echo second user hook"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, userConfig);
|
|
|
|
runCodexInstall(codexHome);
|
|
const afterInstall = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(afterInstall);
|
|
|
|
// hooks.SessionStart must be an array-of-tables (namespaced AoT form).
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
|
'hooks.SessionStart must be an array-of-tables, got: '
|
|
+ (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table')
|
|
);
|
|
|
|
const commands = parsed.hooks.SessionStart.map((entry) => entry.command);
|
|
|
|
// Both pre-existing user hook entries survive in the parsed structure.
|
|
assert.ok(
|
|
commands.includes('echo first user hook'),
|
|
'first user [[hooks.SessionStart]] entry preserved in parsed structure: ' + JSON.stringify(commands)
|
|
);
|
|
assert.ok(
|
|
commands.includes('echo second user hook'),
|
|
'second user [[hooks.SessionStart]] entry preserved in parsed structure: ' + JSON.stringify(commands)
|
|
);
|
|
|
|
// GSD's managed entry is emitted in the same namespaced AoT shape so it
|
|
// does not collide with the user's preferred form.
|
|
assert.ok(
|
|
commands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)),
|
|
'GSD entry must appear in hooks.SessionStart array (not top-level [[hooks]]): '
|
|
+ JSON.stringify(commands)
|
|
);
|
|
|
|
// Top-level [[hooks]] AoT must not coexist when namespaced form is in use —
|
|
// mixing forms is what produces the round-trip break this fix prevents.
|
|
assert.ok(
|
|
!Array.isArray(parsed.hooks) || parsed.hooks.length === 0,
|
|
'no top-level [[hooks]] AoT entries when namespaced form is in use'
|
|
);
|
|
});
|
|
|
|
test('selects top-level [[hooks]] form when user has no namespaced hooks (status-quo behavior)', () => {
|
|
writeCodexConfig(codexHome, '');
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(content);
|
|
|
|
// Top-level hooks must be an array-of-tables; the GSD entry must be one
|
|
// of those tables and carry event = "SessionStart".
|
|
assert.ok(
|
|
Array.isArray(parsed.hooks),
|
|
'fresh install must produce top-level [[hooks]] AoT, got: ' + typeof parsed.hooks
|
|
);
|
|
assert.ok(
|
|
parsed.hooks.some((h) => h && h.event === 'SessionStart'),
|
|
'top-level [[hooks]] AoT must contain an entry with event = "SessionStart": '
|
|
+ JSON.stringify(parsed.hooks)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => {
|
|
writeCodexConfig(codexHome, [
|
|
'[agents]',
|
|
'default = "custom-agent"',
|
|
'extra_key = "value"',
|
|
'',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n'));
|
|
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(content);
|
|
|
|
// Bare [agents] would have left { default, extra_key } as scalar leaves
|
|
// on parsed.agents. After strip + struct emit, every key under agents
|
|
// must itself be a table (the gsd-* struct form).
|
|
assert.ok(
|
|
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
|
'agents must be a table-of-tables in parsed structure, got: ' + typeof parsed.agents
|
|
);
|
|
assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped');
|
|
assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped');
|
|
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
|
assert.ok(
|
|
gsdAgents.length > 0 && gsdAgents.every((k) => typeof parsed.agents[k] === 'object'),
|
|
'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents))
|
|
);
|
|
|
|
// User's unrelated [model] section preserved structurally.
|
|
assert.ok(
|
|
parsed.model && parsed.model.name === 'o3',
|
|
'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model)
|
|
);
|
|
});
|
|
|
|
test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => {
|
|
writeCodexConfig(codexHome, [
|
|
'[[agents]]',
|
|
'name = "user-helper"',
|
|
'description = "third-party agent"',
|
|
'',
|
|
'[[agents]]',
|
|
'name = "another-helper"',
|
|
'description = "second one"',
|
|
'',
|
|
'[projects."/tmp/x"]',
|
|
'trust_level = "trusted"',
|
|
'',
|
|
].join('\n'));
|
|
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(content);
|
|
|
|
// [[agents]] sequence form would parse to Array — after strip it must be
|
|
// a table-of-tables with gsd-* struct keys.
|
|
assert.ok(
|
|
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
|
'agents must be a table-of-tables in parsed structure (sequence form must be stripped), got: '
|
|
+ (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents)
|
|
);
|
|
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
|
assert.ok(
|
|
gsdAgents.length > 0,
|
|
'agents.gsd-* struct form must be present: ' + JSON.stringify(Object.keys(parsed.agents))
|
|
);
|
|
|
|
// User's unrelated [projects."/tmp/x"] section preserved structurally.
|
|
assert.ok(
|
|
parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted',
|
|
'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: '
|
|
+ JSON.stringify(parsed.projects)
|
|
);
|
|
});
|
|
});
|
|
|
|
// concurrency: false — the third test mutates installModule.__codexSchemaValidator,
|
|
// a module-level test seam. Other tests in this file (and in bug-2153, etc.)
|
|
// also call runCodexInstall() and would observe the injected validator if
|
|
// node:test ran them in parallel. Serializing this describe block keeps the
|
|
// seam mutation invisible to siblings.
|
|
describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => {
|
|
test('passes a clean config produced by GSD install', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-'));
|
|
try {
|
|
const codexHome = path.join(tmpDir, 'codex-home');
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.equal(result.ok, true, 'GSD-emitted config passes schema validation');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => {
|
|
const bareAgents = [
|
|
'[agents]',
|
|
'default = "x"',
|
|
'',
|
|
].join('\n');
|
|
const bareHooks = [
|
|
'[hooks.SessionStart]',
|
|
'command = "x"',
|
|
'',
|
|
].join('\n');
|
|
const sequenceAgents = [
|
|
'[[agents]]',
|
|
'name = "x"',
|
|
'',
|
|
].join('\n');
|
|
|
|
assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected');
|
|
assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected');
|
|
assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected');
|
|
});
|
|
|
|
test('aborts install and restores pre-install backup when post-write validation fails', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-'));
|
|
const installModule = require('../bin/install.js');
|
|
try {
|
|
const codexHome = path.join(tmpDir, 'codex-home');
|
|
// Pre-install file the user wants protected.
|
|
const preInstall = [
|
|
'# user file',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
// Force the post-write validator to fail via the documented test seam.
|
|
// This simulates the writer producing legacy-form output that Codex
|
|
// would reject — install MUST abort, restore the pre-install bytes,
|
|
// and surface a clear error.
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'simulated invalid output for test',
|
|
});
|
|
|
|
let threw = false;
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (e) {
|
|
threw = true;
|
|
assert.match(
|
|
e.message,
|
|
/post-write Codex schema validation failed/,
|
|
'thrown error names the validation failure'
|
|
);
|
|
assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason');
|
|
}
|
|
assert.equal(threw, true, 'install threw when validator failed');
|
|
|
|
const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
assert.equal(
|
|
afterInstall,
|
|
preInstall,
|
|
'pre-install file restored verbatim after validation failure'
|
|
);
|
|
} finally {
|
|
delete installModule.__codexSchemaValidator;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#2760 — hasUserNamespacedAotHooks helper', () => {
|
|
test('detects [[hooks.SessionStart]] AoT entries', () => {
|
|
const content = [
|
|
'[[hooks.SessionStart]]',
|
|
'command = "x"',
|
|
'',
|
|
].join('\n');
|
|
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true);
|
|
});
|
|
|
|
test('returns false when only top-level [[hooks]] entries exist', () => {
|
|
const content = [
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "x"',
|
|
'',
|
|
].join('\n');
|
|
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false);
|
|
});
|
|
|
|
test('returns false when only single-bracket [hooks.SessionStart] exists', () => {
|
|
const content = [
|
|
'[hooks.SessionStart]',
|
|
'command = "x"',
|
|
'',
|
|
].join('\n');
|
|
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false);
|
|
});
|
|
});
|
|
|
|
// concurrency: false — these tests monkey-patch fs.writeFileSync, a global
|
|
// shared with every other suite running in parallel. Serializing prevents
|
|
// stray writes from sibling tests landing in the stub.
|
|
describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
let originalWriteFileSync;
|
|
// #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The
|
|
// first test below monkey-patches renameSync; without a beforeEach/afterEach
|
|
// pair, only the local `finally` restores it, which is fragile to future
|
|
// edits that add early-return paths.
|
|
let originalRenameSync;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
originalWriteFileSync = fs.writeFileSync;
|
|
originalRenameSync = fs.renameSync;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.renameSync = originalRenameSync;
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('pre-install config bytes survive when fs.renameSync throws over configPath', () => {
|
|
const preInstall = [
|
|
'# user file',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
// After fs is restored we'll re-read the file. Capture the byte buffer
|
|
// exactly so the comparison is bit-for-bit.
|
|
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
|
|
|
const configPath = path.join(codexHome, 'config.toml');
|
|
const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
|
|
|
|
// Stub: allow writes to atomic temp files (which renameSync overwrites
|
|
// the target, never truncating it directly) but throw on any direct
|
|
// write to the canonical configPath. This simulates either:
|
|
// (a) an older code path doing a non-atomic write, or
|
|
// (b) a downstream module bypassing atomicWriteFileSync.
|
|
// Either way the snapshot must be restored. We let the temp write go
|
|
// through, then make renameSync throw to simulate the partial write
|
|
// never landing.
|
|
// #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level
|
|
// afterEach; no local finally needed.
|
|
fs.renameSync = (src, dst) => {
|
|
if (dst === configPath) {
|
|
throw new Error('simulated rename failure mid-install');
|
|
}
|
|
return originalRenameSync(src, dst);
|
|
};
|
|
|
|
let threw = false;
|
|
let thrownErr = null;
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (e) {
|
|
threw = true;
|
|
thrownErr = e;
|
|
assert.ok(/rename failure|simulated|post-write/.test(e.message),
|
|
'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message);
|
|
}
|
|
// #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write
|
|
// and write failures must be fatal. This test previously accepted either
|
|
// throw OR warn — sibling tests already require throw, so lock parity.
|
|
assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message));
|
|
|
|
const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
|
assert.deepStrictEqual(
|
|
afterBytes,
|
|
preInstallBytes,
|
|
'pre-install config.toml bytes must survive a mid-install write/rename failure'
|
|
);
|
|
|
|
// And the parsed structure of the surviving file must still be the
|
|
// user's [model] section, not a half-written GSD block.
|
|
const parsed = parseTomlToObject(afterBytes.toString('utf8'));
|
|
assert.equal(parsed.model && parsed.model.name, 'o3',
|
|
'surviving file must still be the user pre-install content');
|
|
assert.equal(parsed.agents, undefined,
|
|
'no GSD agents block may have leaked into the surviving file');
|
|
|
|
// No stray .tmp-* siblings left behind in the codex home.
|
|
const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f)));
|
|
assert.equal(stray.length, 0,
|
|
'atomic write must clean up its temp file on failure: ' + stray.join(', '));
|
|
});
|
|
|
|
test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => {
|
|
const preInstall = [
|
|
'# user file',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
|
const configPath = path.join(codexHome, 'config.toml');
|
|
const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
|
|
|
|
// Stub: fault writes targeting the atomic temp file (the pre-rename branch
|
|
// of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME)
|
|
// pass through. This exercises the failure path where the temp write itself
|
|
// throws, not the rename — the case the prior test left untested.
|
|
// #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level
|
|
// afterEach (via originalWriteFileSync); no local finally needed.
|
|
const captured = originalWriteFileSync;
|
|
fs.writeFileSync = function patchedWriteFileSync(target, data, options) {
|
|
if (typeof target === 'string' && tempPattern.test(target)) {
|
|
throw new Error('simulated writeFileSync failure on .tmp- target');
|
|
}
|
|
return captured.call(this, target, data, options);
|
|
};
|
|
|
|
let threw = false;
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (e) {
|
|
threw = true;
|
|
assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message),
|
|
'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message);
|
|
}
|
|
// Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn).
|
|
assert.equal(threw, true, 'install must throw when atomic temp-write fails');
|
|
|
|
const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
|
assert.deepStrictEqual(
|
|
afterBytes,
|
|
preInstallBytes,
|
|
'pre-install config.toml bytes must survive a temp-write failure'
|
|
);
|
|
|
|
const parsed = parseTomlToObject(afterBytes.toString('utf8'));
|
|
assert.equal(parsed.model && parsed.model.name, 'o3',
|
|
'surviving file must still be the user pre-install content');
|
|
assert.equal(parsed.agents, undefined,
|
|
'no GSD agents block may have leaked into the surviving file');
|
|
|
|
const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f)));
|
|
assert.equal(stray.length, 0,
|
|
'atomic write must clean up its temp file on failure: ' + stray.join(', '));
|
|
});
|
|
});
|
|
|
|
// concurrency: false — these tests rely on the same install path and module-
|
|
// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing
|
|
// keeps state mutations from leaking across parallel siblings.
|
|
describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => {
|
|
// Reproduce the upgrade scenario:
|
|
// - User has [[hooks.SessionStart]] entry of their own (signal that GSD
|
|
// should emit in the namespaced shape).
|
|
// - A previous GSD install left the legacy flat [[hooks]] managed block
|
|
// for gsd-check-update. The pre-CR4 strip step would short-circuit
|
|
// the namespaced emit and leave the user stuck in the mixed layout.
|
|
const userPlusLegacy = [
|
|
'[[hooks.SessionStart]]',
|
|
'command = "echo user hook"',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "node /old/path/hooks/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, userPlusLegacy);
|
|
|
|
runCodexInstall(codexHome);
|
|
const afterInstall = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(afterInstall);
|
|
|
|
// After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped
|
|
// and the GSD entry is re-emitted in the namespaced AoT shape so the two
|
|
// forms do not coexist.
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
|
'hooks.SessionStart must be an array-of-tables, got: '
|
|
+ (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table')
|
|
);
|
|
|
|
const namespacedCommands = parsed.hooks.SessionStart.map((entry) => entry.command);
|
|
assert.ok(
|
|
namespacedCommands.includes('echo user hook'),
|
|
'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(namespacedCommands)
|
|
);
|
|
assert.ok(
|
|
namespacedCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)),
|
|
'GSD entry must appear in hooks.SessionStart array (namespaced AoT form): '
|
|
+ JSON.stringify(namespacedCommands)
|
|
);
|
|
|
|
// The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced
|
|
// form after migration. parseTomlToObject distinguishes via Array.isArray.
|
|
assert.ok(
|
|
!Array.isArray(parsed.hooks) || parsed.hooks.length === 0,
|
|
'no top-level [[hooks]] AoT entries may remain after legacy migration: '
|
|
+ JSON.stringify(parsed.hooks)
|
|
);
|
|
|
|
// No duplicate gsd-check-update entries — exactly one managed entry.
|
|
const gsdEntries = namespacedCommands.filter(
|
|
(cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)
|
|
);
|
|
assert.equal(gsdEntries.length, 1,
|
|
'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length);
|
|
});
|
|
});
|
|
|
|
describe('#2760 CR4 finding 3 — parseTomlToObject rejects malformed input that previously slipped through', () => {
|
|
test('rejects float values (timeout = 0.5)', () => {
|
|
const content = [
|
|
'[server]',
|
|
'timeout = 0.5',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/unsupported TOML value|trailing bytes/,
|
|
'float values must be rejected, not silently truncated to int prefix'
|
|
);
|
|
});
|
|
|
|
test('rejects date values (created = 1979-05-27)', () => {
|
|
const content = [
|
|
'[meta]',
|
|
'created = 1979-05-27',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/unsupported TOML value|trailing bytes/,
|
|
'date values must be rejected, not silently truncated'
|
|
);
|
|
});
|
|
|
|
test('rejects trailing garbage after a string value (key = "x" junk)', () => {
|
|
const content = [
|
|
'[section]',
|
|
'key = "x" junk',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/trailing bytes/,
|
|
'trailing bytes after a complete value must be rejected'
|
|
);
|
|
});
|
|
|
|
test('accepts trailing whitespace and # comment after a value', () => {
|
|
const content = [
|
|
'[section]',
|
|
'key = "x" # an inline comment',
|
|
'flag = true',
|
|
'count = 7 ',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.equal(parsed.section.key, 'x');
|
|
assert.equal(parsed.section.flag, true);
|
|
assert.equal(parsed.section.count, 7);
|
|
});
|
|
});
|
|
|
|
// concurrency: false — see the fix-3 suite above for the same rationale.
|
|
describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
let originalRenameSync;
|
|
let originalConsoleLog;
|
|
let consoleOutput;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
originalRenameSync = fs.renameSync;
|
|
originalConsoleLog = console.log;
|
|
consoleOutput = [];
|
|
console.log = (...args) => { consoleOutput.push(args.join(' ')); };
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.renameSync = originalRenameSync;
|
|
console.log = originalConsoleLog;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => {
|
|
const preInstall = [
|
|
'# user file',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
const configPath = path.join(codexHome, 'config.toml');
|
|
// Only fault the hook-block atomic rename — earlier writes to config.toml
|
|
// happen via mergeCodexConfig (agent-block emit). We want to exercise the
|
|
// post-write Codex install branch specifically. Detect by reading the temp
|
|
// file's contents and only faulting when the hook block is present.
|
|
fs.renameSync = (src, dst) => {
|
|
if (dst === configPath) {
|
|
let isHookWrite = false;
|
|
try {
|
|
const data = fs.readFileSync(src, 'utf8');
|
|
isHookWrite = /gsd-check-update\.js/.test(data);
|
|
} catch (_) { /* ignore */ }
|
|
if (isHookWrite) {
|
|
throw new Error('simulated rename failure');
|
|
}
|
|
}
|
|
return originalRenameSync(src, dst);
|
|
};
|
|
|
|
let threw = false;
|
|
let thrownMessage = '';
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (e) {
|
|
threw = true;
|
|
thrownMessage = e.message;
|
|
}
|
|
|
|
assert.equal(threw, true, 'install must throw when atomic write fails');
|
|
assert.match(
|
|
thrownMessage,
|
|
/post-write Codex install failed/,
|
|
'thrown error must use the post-write prefix so the outer catch treats it as fatal'
|
|
);
|
|
|
|
// Critical: install must NOT have printed any "Done!" success banner.
|
|
const printedDone = consoleOutput.some(
|
|
(line) => typeof line === 'string' && /Done!/i.test(line)
|
|
);
|
|
assert.equal(printedDone, false,
|
|
'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l))));
|
|
|
|
// And the user's pre-install bytes are intact (snapshot restore).
|
|
const after = fs.readFileSync(configPath, 'utf8');
|
|
assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort');
|
|
});
|
|
});
|
|
|
|
// concurrency: false — patches module.exports.__codexSchemaValidator, a
|
|
// shared test seam. Serializing prevents stray patches from sibling tests.
|
|
describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
let originalConsoleLog;
|
|
let consoleOutput;
|
|
const installModule = require('../bin/install.js');
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
originalConsoleLog = console.log;
|
|
consoleOutput = [];
|
|
console.log = (...args) => { consoleOutput.push(args.join(' ')); };
|
|
});
|
|
|
|
afterEach(() => {
|
|
console.log = originalConsoleLog;
|
|
delete installModule.__codexSchemaValidator;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => {
|
|
// A validator that THROWS (vs returning {ok:false}) bypasses the
|
|
// validation branch and exits the inner try via the catch at the outer
|
|
// level. Pre-CR5, that catch downgraded to console.warn and let the
|
|
// install print "Done!" with no Codex hooks. Post-CR5 it must rethrow.
|
|
const preInstall = [
|
|
'# user file',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
installModule.__codexSchemaValidator = () => {
|
|
throw new Error('synthetic validator-throw simulating a pre-write helper failure');
|
|
};
|
|
|
|
let threw = false;
|
|
let thrownMsg = '';
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (e) {
|
|
threw = true;
|
|
thrownMsg = e.message;
|
|
}
|
|
|
|
assert.equal(threw, true,
|
|
'install must rethrow when a pre-write step throws (CR5 finding 1)');
|
|
assert.match(thrownMsg, /pre-write|synthetic validator-throw/,
|
|
'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg);
|
|
|
|
const printedDone = consoleOutput.some(
|
|
(line) => typeof line === 'string' && /Done!/i.test(line)
|
|
);
|
|
assert.equal(printedDone, false,
|
|
'install must NOT print "Done!" after a pre-write failure: ' +
|
|
JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l))));
|
|
|
|
// Pre-install bytes intact (snapshot restored).
|
|
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
assert.equal(after, preInstall,
|
|
'pre-install bytes must survive a pre-write helper throw');
|
|
});
|
|
});
|
|
|
|
describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => {
|
|
test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => {
|
|
const content = [
|
|
'[a]',
|
|
'x = 1',
|
|
'x = 2',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/duplicate key/,
|
|
'real TOML 1.0 rejects duplicate keys in the same table'
|
|
);
|
|
});
|
|
|
|
test('rejects duplicate scalar key in root table', () => {
|
|
const content = [
|
|
'x = 1',
|
|
'x = 2',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/duplicate key/,
|
|
'duplicate root-table keys must be rejected'
|
|
);
|
|
});
|
|
|
|
test('rejects re-declared [a] table header ([a] then [a] again)', () => {
|
|
const content = [
|
|
'[a]',
|
|
'x = 1',
|
|
'',
|
|
'[a]',
|
|
'y = 2',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/duplicate or shape-mismatched table header/,
|
|
'real TOML 1.0 rejects re-declaring the same [a] header twice'
|
|
);
|
|
});
|
|
|
|
test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => {
|
|
const content = [
|
|
'[[arr]]',
|
|
'x = 1',
|
|
'',
|
|
'[arr]',
|
|
'y = 2',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/duplicate or shape-mismatched table header/,
|
|
'cannot redeclare an array-of-tables path as a plain table'
|
|
);
|
|
});
|
|
|
|
test('accepts repeated [[arr]] (genuine array-of-tables)', () => {
|
|
const content = [
|
|
'[[arr]]',
|
|
'x = 1',
|
|
'',
|
|
'[[arr]]',
|
|
'x = 2',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.ok(Array.isArray(parsed.arr));
|
|
assert.strictEqual(parsed.arr.length, 2);
|
|
assert.strictEqual(parsed.arr[0].x, 1);
|
|
assert.strictEqual(parsed.arr[1].x, 2);
|
|
});
|
|
|
|
test('accepts disjoint nested headers (not duplicates)', () => {
|
|
const content = [
|
|
'[a.b]',
|
|
'x = 1',
|
|
'',
|
|
'[a.c]',
|
|
'y = 2',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.a.b.x, 1);
|
|
assert.strictEqual(parsed.a.c.y, 2);
|
|
});
|
|
});
|
|
|
|
// concurrency: false — drives the same install pipeline as the other f-suites.
|
|
describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => {
|
|
// Reproduces the mixed-form scenario from finding 3:
|
|
// - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]]
|
|
// AND a legacy single-bracket [hooks.SessionStart].
|
|
// - Pre-CR5 migration converts the legacy section to flat [[hooks]]
|
|
// with event="SessionStart", leaving a mixed flat+namespaced layout.
|
|
// - Post-CR5 migration emits [[hooks.SessionStart]] directly so both
|
|
// of the user's hooks coexist in the namespaced shape, and the
|
|
// GSD-managed entry converges on namespaced too.
|
|
const userPlusLegacy = [
|
|
'[[hooks.AfterTool]]',
|
|
'command = "x"',
|
|
'',
|
|
'[hooks.SessionStart]',
|
|
'command = "y"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, userPlusLegacy);
|
|
|
|
runCodexInstall(codexHome);
|
|
const after = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(after);
|
|
|
|
// The pre-existing [[hooks.AfterTool]] entry is preserved.
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.AfterTool),
|
|
'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array'
|
|
);
|
|
assert.ok(
|
|
parsed.hooks.AfterTool.some((entry) => entry.command === 'x'),
|
|
'user AfterTool entry must be preserved: ' + JSON.stringify(parsed.hooks.AfterTool)
|
|
);
|
|
|
|
// The migrated SessionStart entry is now namespaced AoT, not flat
|
|
// [[hooks]] with event="SessionStart".
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
|
'migrated SessionStart must be namespaced AoT (not flat [[hooks]])'
|
|
);
|
|
const ssCommands = parsed.hooks.SessionStart.map((e) => e.command);
|
|
assert.ok(
|
|
ssCommands.includes('y'),
|
|
'user SessionStart command "y" must be preserved in namespaced array: ' +
|
|
JSON.stringify(ssCommands)
|
|
);
|
|
// GSD's managed gsd-check-update entry also lives in the namespaced array.
|
|
assert.ok(
|
|
ssCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update\.js/.test(cmd)),
|
|
'managed gsd-check-update entry must appear in hooks.SessionStart array: ' +
|
|
JSON.stringify(ssCommands)
|
|
);
|
|
|
|
// No flat top-level [[hooks]] AoT may remain.
|
|
assert.ok(
|
|
!Array.isArray(parsed.hooks) || parsed.hooks.length === 0,
|
|
'no flat top-level [[hooks]] AoT entries may remain after migration: ' +
|
|
JSON.stringify(parsed.hooks)
|
|
);
|
|
|
|
// No synthetic event field on the migrated SessionStart entries — the
|
|
// namespace IS the event.
|
|
for (const entry of parsed.hooks.SessionStart) {
|
|
assert.equal(entry.event, undefined,
|
|
'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' +
|
|
JSON.stringify(entry));
|
|
}
|
|
});
|
|
});
|