* test(260903-m7p): expose configured-entrypoint validation gap * enhance(260903-m7p): validate configured entrypoints before success * test(260903-m7p): require pre-success entrypoint validation * enhance(260903-m7p): gate install success on entrypoints * test(260903-m7p): cover configured entrypoints across runtimes * enhance(260903-m7p): cover emitted runtime entrypoints * fix(260903-m7p): sandbox HOME in finishInstall test and fix changeset pr number - finishInstall(...'cline'...) calls writeNonClaudeDefaults(runtime) in-process before the new configured-entrypoint assertion throws. Without a HOME + config-location-env sandbox that write resolved through the ambient environment and landed in the developer's live ~/.gsd (confirmed absent on origin/next baseline, present only on this branch — full-suite HERMETICITY WARNING). Sandbox HOME/USERPROFILE and scrub config-location env for the duration of the test, matching the existing in-process finishInstall/ install() pattern in tests/install.test.cjs (#2665). - .changeset/quick-wasps-sing.md: pr: 0 is a never-backfilled placeholder (CONTRIBUTING.md) that fails changeset-lint's invalid_pr check; set to the fork PR number until the upstream PR number is known. * fix(260903-m7p): repair cross-platform and pre-existing shape fallout - tests/configured-entrypoint-validation.test.cjs: the win32 branch of ensureCodexHooksJsonSessionStart writes a .cmd shim under <codexRoot>/hooks/; create that dir in the test (the real installer only calls this once hooks/gsd-check-update.js already exists) and assert the platform-common entrypoint shape instead of a fixed non-Windows array, since win32 legitimately emits two entries (cmd shim + script). - tests/install.test.cjs: finishInstall's shared settings-json return now carries configuredEntrypoints/rollbackInstallerMigrations for every runtime on that path (trae included, not just Claude/Cursor/Windsurf); update the trae install() exact-shape assertion to match. * fix(260903-m7p): keep .sh interpreter tracking consistent with unresolved bash configuredEntrypointsForHook's shell branch dropped interpreterCandidates entirely when resolveBashExecutable returned null, unlike the sibling portableHooks runner entry a few lines below (which correctly falls back to the literal 'bash' token). Found via agy adversarial review; verified unreachable through the current call graph (buildHookCommand's own resolveBashRunner==null gate already short-circuits before recordConfiguredHookCommand runs), so this is a defensive consistency fix, not a live-bug patch — kept for the next caller that does not share that gate. * chore(260903-m7p): backfill changeset pr number to the opened upstream PR .changeset/quick-wasps-sing.md carried the fork PR number (16) as a placeholder until the upstream PR existed; open-gsd/gsd-core#4249 is now open, so record its real number per CONTRIBUTING.md's changeset pr-field convention. * fix(#4154): track already-registered hooks for entrypoint validation on update applySettingsJsonHooks registers each guard hook only if absent, so a hook already present from a prior install keeps its stale on-disk command. The new entrypoint tracker always records the freshly-computed command for it, which never matches what is actually persisted, so the exact-string filter in finishInstall silently dropped it from validation — the Blocker case this feature exists to catch (an already-installed entrypoint going stale between installs) was exactly the case it never validated. Match on the managed script's basename instead, which the persisted command carries either way, so an already-registered hook stays in the validated set. Regression test forces this path by mutating a freshly-installed hook's persisted command before a second install. * fix(#4154): distinguish an unreadable script from a missing one validateConfiguredEntrypoints folded an EACCES statSync failure into the same 'missing' reason as ENOENT, misreporting a real permission problem as an absent file. Check the error code and report 'unreadable' instead. * docs(#4154): document entrypoint validation's rollback and PATH scope CONTEXT.md's Runtime Hooks Surface Module / Installer Module entries had no mention of ConfiguredEntrypoint/validateConfiguredEntrypoints, despite bin/install.js x CONTEXT.md being this repo's strongest co-change pairing. The update-gsd.md how-to overstated what a validation failure undoes: for Codex/Cursor/Windsurf/Kimi, their own writer already persisted hooks.json/ config.toml inside install() before the aggregate validation call runs, so there is no rollback path for that write regardless of "where available" phrasing. Also note that interpreter resolution checks the installer's own PATH, not necessarily the PATH a hook fires under later (#2979 launchers). * chore(#4154): point changeset pr field at the fork PR while CI runs there Mirrors the branch's own prior backfill commit: pr: matches whichever PR number changeset-lint is currently validating against (fork PR #16 during the fork-first CI/review loop), flipped back to the upstream PR number right before the final push to open-gsd/gsd-core. * fix(#4249): address adversarial-review findings in entrypoint validation An internal adversarial review (agy/gemini-3.8-flash-high) of the whole PR found several real gaps beyond the human reviewer's Blocker, verified against source before fixing: - Codex's install() result bound rollbackInstallerMigrations to the narrow installer-migrations-only rollback instead of restoreCodexSnapshot (#3245), the full pre-install snapshot/restore Codex already owns for exactly this case — a validation failure discovered outside install() reverted nothing of the config.toml/hooks.json that call had already written. - The register-only-if-absent basename match from the prior fix used a bare substring, which an unrelated user command mentioning the same filename could false-positive into GSD's validated set — anchored on the `/hooks/<basename>` path segment instead. - nodeCandidates checked raw process.execPath (always true — we're running in that process) instead of normalizeNodePath's stable version-manager alias, the same one buildNodeRunnerChainToken bakes as its first choice — a false green regardless of whether that alias itself still resolves. - An entry with no interpreterCandidates (Cline's PreToolUse hook, or a Windows-Claude .sh hook invoked without a bash runner) runs via its own shebang; validateConfiguredEntrypoints checked only file-type, never the execute bit. Cline's writer also never reported an entrypoint at all. - Duplicate (configPath, scriptPath) entries (e.g. Kimi's context-monitor hook registered across several events) were validated once per duplicate. Each fix is covered by a new or extended test; the Codex one required inlining runCodexInstall's env sandboxing so the rollback closure — which re-resolves the $HOME-relative skills root live — runs before the sandbox is torn down, matching how installAllRuntimes' real aggregate gate calls it. * docs(#4249): document the round-2 entrypoint-validation fixes Runtime Hooks Surface Module and Installer Module entries now name ConfiguredEntrypoint's not-executable reason, the normalizeNodePath alignment, Cline's tracked hook, and which install() result the finishInstall/installAllRuntimes rollback path actually reverts per runtime (Codex's full snapshot vs. the others' narrow migrations-only rollback). * chore(#4249): point changeset pr field at the upstream PR now that fork CI is green * fix(#4249): address agy adversarial-review findings - validateConfiguredEntrypoints: statSync alone never detects a chmod-000 script (it only needs parent-dir search permission), so an interpreter-invoked entry with an unreadable script passed validation. Add an explicit R_OK check for the interpreterCandidates branch only — the candidate-less/shebang branch already has its own X_OK gate. - docs/how-to/update-gsd.md: the blanket "does not revert" claim was false for Codex, which reverts config.toml/hooks.json via its full pre-install snapshot; qualify it per runtime. - tests/codex-config.test.cjs: the #4249 rollback regression test asserted skills/ and VERSION were reverted but never asserted config.toml/hooks.json were too, despite the test's own stated intent. - CONTEXT.md: qualify which interpreterCandidates entries get normalizeNodePath'd (Node hooks only, not .sh/bash) and note Codex's Windows .cmd shim as a third candidate-less case that relies on extension dispatch, not a shebang. * fix(#4249): validate Cline's PATH-dependent interpreter, not just its execute bit Cline's hook is a hybrid: it self-executes via '#!/usr/bin/env node', so it needs the execute bit (like any shebang-invoked entry), but its interpreter is looked up on PATH by 'env' at hook-fire time (unlike every other GSD JS hook, which bakes an absolute node path specifically to avoid that dependency). The candidate-less/interpreterCandidates fork treated these as mutually exclusive, so Cline's entry silently skipped interpreter resolution entirely — a completely missing 'node' on PATH would still validate successfully. Add an orthogonal selfExecutable flag so both checks run for entries that need them. (CodeRabbit finding on the fork rehearsal PR.) * fix(#4249): address second-round adversarial review findings (opus + agy) - validateConfiguredEntrypoints: R_OK now runs for every scriptOk entry, not just interpreterCandidates ones — a self-executable shebang script is still opened and read by its kernel-invoked interpreter, so X_OK alone never proved it was readable. - selfExecutable is now the sole, explicit source of truth for the execute-bit check (every producer that needs it sets the flag) instead of being partly inferred from an absent interpreterCandidates, which Cline's hybrid entry also carries. - The execute-bit check now skips explicitly on win32 (matching resolveExecutableBinary's own carve-out) instead of relying on Node's accessSync(X_OK)-as-F_OK no-op, which only protects a real Windows machine and not a test that simulates win32 on a POSIX runner. - bin/install.js: fixed a stale comment claiming no runtime's install()-time writes have a rollback path — Codex's does (restoreCodexSnapshot) — and added the omitted Cline to both that comment and CONTEXT.md's equivalent lists. - CONTEXT.md: fixed the Cline description left stale by the previous commit's selfExecutable addition, and rewrote the validation-mechanism paragraph for clarity (writing-for-agents pass). - docs/how-to/update-gsd.md: split an overloaded 4-clause sentence. - Removed a fault-injection integration test that could not reliably exercise the real installAllRuntimes -> finalize -> rollback wiring without fighting the installer's own pre-registration existence guards; the constituent pieces remain covered individually. * fix(#4249): pin platform in X_OK-testing entries so they're deterministic cross-CI-runner X_OK is a POSIX-only concept, skipped entirely when an entry's platform is win32 (matching production). Two test entries omitted platform, defaulting to process.platform — on an actual windows-latest CI runner that silently skipped the very check they were meant to exercise, turning 'not-executable' into a false pass. Pin platform: 'linux' so these are deterministic regardless of which OS runs the suite. * fix(#4249): classify EPERM the same as EACCES in statSync error handling Windows raises EPERM (not EACCES) for a parent directory that couldn't be traversed into — was falling through to 'missing', misreporting a genuine permission problem as a nonexistent path. * docs(#4249): address final CodeRabbit doc-completeness findings - CONTEXT.md: install()'s documented result shape omitted configuredEntrypoints; the ConfiguredEntrypoint shape omitted selfExecutable. - docs/how-to/update-gsd.md: the failure-mode sentence omitted unreadable and lacks-execute-permission, which the installer also rejects. * fix(#4249): stop double-validating every configured entrypoint on install/update installAllRuntimes' finalize() already runs assertConfiguredEntrypoints once over the aggregate set; finishInstall then re-ran the identical check per runtime in the printSummaries loop right after, so every entrypoint paid its statSync/accessSync/interpreter-resolution cost twice on every install and update. Add entrypointsAlreadyValidated to skip the redundant pass specifically on that path, while leaving the check intact for any caller that invokes finishInstall directly. * chore(#4154): point changeset pr field at rehearsal fork PR while CI runs there * perf(#4249): memoize interpreter candidate resolution across entrypoints resolveExecutableBinary walked PATH once per (entry, candidate) pair; a typical install has a dozen-plus entries sharing the same few candidate lists (process.execPath for JS hooks, bash for shell hooks). Cache by (platform, candidate) so each distinct pair resolves once per validation call instead of once per entry. * chore(#4249): point changeset pr field at the rebased rehearsal fork PR * fix(#4249): drop entrypoint tracking from the now-dead Codex event writer #2586 (landed on next after this branch forked) removed install.js's CODEX_EXTENDED_HOOK_EVENTS registration loop, so ensureCodexHooksJsonEvent no longer runs during install or update. The ConfiguredEntrypoint records this branch added inside it were therefore unreachable and untested. Restore the function to its upstream shape; the entrypoints it used to report were never collected by any caller. * refactor(#4249): drop the revalidation bypass flag and the candidate cache Both were this PR's own micro-optimisations over a set of roughly a dozen entries. `entrypointsAlreadyValidated` let a caller turn the finishInstall gate off to save one statSync/accessSync pass; `resolvedCandidateCache` memoised resolveExecutableBinary across entries that are already deduped by (configPath, scriptPath). Neither is measurable, and the flag was the only way to reach finishInstall with validation disabled. finishInstall now always validates what it is given. * chore(#4249): point the changeset pr field back at the upstream PR * refactor(#4249): track settings.json entrypoints without the hooksSurface gate The install-surface writer only tracked configured entrypoints when the runtime's descriptor also declared `hooksSurface: 'settings-json'`. Nothing asserts that axis agrees with `installSurface`, so a descriptor that broke the coupling would silently pass `configuredEntrypoints: undefined` and drop that runtime out of the validation this PR adds — reintroducing the exact 'reports Done! over a broken entrypoint' failure #4154 exists to close. Remove the dependence rather than test it: everything recorded on this path lands in settings.json by construction, and the registered-command filter already discards entries no persisted hook references. * chore(#4249): put the changeset body in the documented two-part format CONTRIBUTING.md and .changeset/README.md both show `**<bold change>** — <symptom-led explanation>.`; the fragment was a single unbolded sentence. * chore(#4249): point the changeset pr field at the rehearsal fork PR while CI runs there * fix(#4249): restore the whole manifest-tracked GSD file set on Codex rollback #3245's snapshot covers config.toml, hooks.json, skills/gsd-*, agents/gsd-* and gsd-core/VERSION. The install overwrites every other GSD-owned file too — hooks/, gsd-core/CHANGELOG.md, scripts/, gsd-core/.gsd-runtime, the manifest itself — before the entrypoint-validation gate runs, so a validation failure left the new payload sitting on top of the restored old config. Snapshot the file set the PREVIOUS install's gsd-file-manifest.json claims, before runInstallerMigrations so the bytes are the true pre-install state, and restore it from both Codex rollback closures ahead of the per-surface restores. Files only the failed install introduced are removed, read from the manifest now on disk. The manifest is already the authoritative record of what GSD owns, so no second hand-written list can drift out of sync, and user-owned files are never snapshotted or removed. Every path is confined through resolveInstallRelativePath, so a hand-edited manifest cannot turn rollback into an arbitrary-path write. Non-Codex runtimes are unaffected: the snapshot is gated on the same tomlConfigInstall + non-minimal condition as #3245's. * fix(#4249): keep the managed-file snapshot honest in minimal mode and on a bad manifest Two follow-on defects in the previous commit's snapshot: - The capture was gated on `!isMinimalMode`, copied from #3245. A core/ --minimal Codex install still writes gsd-core/, hooks/, scripts/ and the manifest, and restoreCodexSnapshot is reachable in that mode (#2695), so the snapshot came back empty while the rollback still ran — and its removal pass would have deleted every file the new manifest lists. Gate on tomlConfigInstall alone, matching where the rollback actually reaches. - An unreadable or unparseable prior manifest was caught alongside ENOENT and treated as a fresh install. That is the same empty-snapshot state, so a failed update over a real install with a corrupt manifest could delete its prior payload. Track whether the pre-install GSD-owned set is KNOWN: ENOENT means known-empty; any other read error or a parse failure means unknown, and the restore closure returns without touching anything, degrading to #3245's narrower rollback. Deliberately not fatal — a corrupt manifest has to stay repairable by reinstalling over it. Both paths are covered by red-checked regression tests. * fix(#4249): snapshot Codex skills, agents and VERSION in minimal mode too commit removed from the manifest snapshot. restoreCodexSnapshot is reachable for a core/--minimal install (#2695), and its pass-2 sweeps remove every gsd-* skill dir and gsd-* agent file the snapshot does not claim — so with an empty minimal-mode snapshot a rollback deleted the whole skills/agents surface with nothing to restore it from. Codex resolves skills to $HOME/.agents/skills via the ADR-1239 skills-kind home override, so this is also the reason manifest `skills/` keys do not resolve under configDir: that surface belongs to this snapshot, not to the manifest-driven one. Gate on tomlConfigInstall alone. _codexPreConfigRollback stays null in minimal mode — doing nothing on an early failure is the non-destructive side. Covered by a red-checked regression test that plants bytes in an alternate-home skill file, reinstalls under the core profile marker, and asserts the rollback restores it. * fix(#4249): never remove on rollback unless a prior manifest proves what predates the install Three defects in the manifest-driven Codex rollback, all in its removal half: - ENOENT marked the snapshot usable, arming the removal pass on a FIRST install. GSD may have overwritten a user's file at a manifest-tracked path there, and no prior manifest records the difference — so rollback deleted it where before it merely left it overwritten. Absent, unreadable and malformed manifests now all leave the prior set UNKNOWN and skip removal entirely. - Membership was tested against the map of files whose pre-install read SUCCEEDED, so a tracked file that existed but was unreadable read as introduced-by-this-install and was removed. Track the prior manifest's paths in their own Set and test against that. - The unreachable "delete the manifest when there was no prior one" branch is gone: usable now implies a parsed prior manifest. Also adds the end-to-end test the aggregate gate was missing — the four Codex rollback tests drove the closure directly, proving the restore but not the wiring. installAllRuntimes(['codex','cline']) under an emptied PATH makes Cline's `env node` entry fail validation for real, and asserts Codex's payload comes back. Test preamble (HOME/USERPROFILE sandbox + config-env scrub) is now one helper instead of six copies. Both new tests are red-checked. * test(#4249): use unlinkSync, not rmSync, to drop the manifest in a test lint:ci's raw-fs.rmSync rule points tests at helpers.cleanup for its Windows-EBUSY retry budget. That budget is for directory trees; this removes a single file, which unlinkSync says more precisely and the rule does not flag. * chore(#4249): point the changeset pr field back at the upstream PR * fix(#4249): use an unambiguous dedup key and surface partial-restore failures trek-e's 2026-09-08 adversarial pass flagged two findings in the new entrypoint-validation/rollback code: - assertConfiguredEntrypoints' dedup key already used a raw NUL separator (introduced in ceebb65f2d), but git/Read render NUL as a space, so the key looked like a plain-space join to every reviewer that read the diff. Replace it with JSON.stringify([configPath, scriptPath]) so the separator is visible and unambiguous. - restoreManagedFileSnapshot's per-file restore catch block claimed to 'surface the original error' but only swallowed it, matching (and widening) the pre-existing #3245 restoreCodexSnapshot pattern. Add an actual console.warn using the existing best-effort-warning convention, scoped to just this PR's new function. * fix(#4249): treat a files-less prior manifest as unknown, not known-empty agy's gemini-3.8-flash-high adversarial pass (round 5) found and I reproduced empirically: a structurally-valid manifest missing the files key (e.g. {"version":1}) parses without throwing, so Object.keys(undefined || {}) silently read as 'zero files predate this install' instead of the UNKNOWN state the malformed-manifest guard exists to produce. Rollback's removal pass then deleted every GSD-owned file the failed install's own manifest listed, including ones that predated it — the exact data loss the #4249 CodeRabbit malformed-manifest fix was supposed to prevent, reachable through a JSON.parse success instead of a failure. Route the shapeless case into the same catch-all UNKNOWN path via an explicit shape check. Regression test reproduces the deletion before the fix and confirms the file survives after it. Also extend restoreManagedFileSnapshot's removal-pass rmSync and final manifest-rewrite catches with the same real console.warn trek-e's round-4 review asked for on the per-file restore catch — same rollback function, same operator-facing-signal gap. * docs(#4249): correct which runtimes actually leave a written config on rollback agy's completeness audit (round 5, holistic pass) caught this new paragraph claiming 'for every other runtime, the configuration file(s) already written during that update are left in place' — false for Claude Code and other settings.json-based runtimes, whose write never happens on failure (assertConfiguredEntrypoints runs before finishInstall's writeSettings). Only Cursor/Windsurf/Kimi/Cline actually match that description, since they persist their config file inside install() ahead of the gate. Split the one sentence into the three actual outcomes; matches the PR body's own accurate Before/After wording, which this doc addition had drifted from. * fix(#4249): clean up doc/comment mismatches and dead fields from opus review Opus critical-code-reviewer + ponytail-review pass on the final diff: - assertConfiguredEntrypoints carried finishInstall's old docblock ("Apply statusline config, then print completion message") from before this function was inserted between comment and callee. finishInstall already has its own accurate #4249 comment, so the stale docblock is removed rather than moved. - checked: number on ConfiguredEntrypointValidationResult and error.configuredEntrypointValidation on the thrown error: the first had zero consumers anywhere in the repo, including its own defining file, and is removed. The second matches an existing repo convention (bin/install.js's installerMigrationRollbackFailures, #4249 predates this PR) of attaching structured diagnostic context to a re-thrown Error even before a consumer exists, so it's kept. - finishInstall's own assertConfiguredEntrypoints call is a redundant backstop on the real production path (installAllRuntimes's aggregate call already validates the superset first), but its comment read as though this call alone provided the before-the-write guarantee. Clarified rather than removed — it's the only gate for a caller that invokes finishInstall directly. * chore(#4249): split the manifest-driven rollback engine out into #4544 Issue #4154 asked the installer to consume a validation failure "through the existing rollback mechanism, without a second transaction mechanism". The manifest-driven rollback widening added during review (capture every path the prior gsd-file-manifest.json claims, restore those bytes, remove what only the failed install introduced) is that second mechanism on a plain reading. It is a real fix for a #3245-era gap, but an independent one, so it moves to its own bug report and PR. Removed here: - bin/install.js: the pre-install managed-file capture block and restoreManagedFileSnapshot, plus its call sites in _codexPreConfigRollback and restoreCodexSnapshot (99 lines). - tests/configured-entrypoint-validation.test.cjs: the five tests that exercise the manifest engine. - CONTEXT.md and docs/how-to/update-gsd.md: the sentences describing the widened restore. update-gsd.md again documents the #3245 surfaces only. Kept, because it is #4154's own scope: - the entrypoint-validation gate itself; - Codex's install() result binding rollbackInstallerMigrations to restoreCodexSnapshot (config.toml, hooks.json, skills/gsd-*, agents/gsd-*, gsd-core/VERSION); - the !isMinimalMode gate removal on that snapshot. Binding the closure to the result made it reachable for a core/--minimal install, where its pass-2 sweeps delete every gsd-* skill dir and agent file the snapshot does not claim; an empty minimal-mode snapshot therefore deleted the whole surface with nothing to restore. The surviving aggregate-failure test now asserts on config.toml, a surface the #3245 snapshot owns, instead of gsd-core/CHANGELOG.md, which only the manifest engine restored. Refs #4544 * test(#4249): cover configured entrypoints through the packed install path #4154's scope lists install smoke coverage alongside the installer gate — "assert representative configured entrypoints resolve for supported runtime profiles". The gate itself (assertConfiguredEntrypoints / validateConfiguredEntrypoints) is unit-covered by in-process install() calls; nothing proved the property survives npm pack -> npm install -g -> install.js. Add Cycle 4 to runSmoke. For each of claude and codex — the two distinct config surfaces GSD writes launch paths into (settings.json, and hooks.json + config.toml) — run the tarball-installed installer into a throwaway HOME, then re-read that runtime's own written config and return the new ENTRYPOINT_UNRESOLVED code when a script path it names does not resolve to a file. install-smoke.yml already asserts .code == "ok" on the CLI, so the check becomes a release gate on every matrix host without workflow changes. The scan re-derives paths from the written config instead of reusing the installer's own entrypoint list, and test I shows why that matters: a registration the installer never touched during a run is invisible to the in-process gate, so the install exits 0 and only reading the config back off disk catches the dangling launch path. * ci(#4249): pack a publish-shaped tarball in the install smoke lane `npm pack` runs prepack/prepare (build:lib); only prepublishOnly runs build:hooks. hooks/dist is gitignored, so the tarball install-smoke.yml packs after `npm ci` carries no hook scripts at all — the lane has been smoking a package that differs from the published one in exactly the artifacts the lifecycle smoke is supposed to launch. That went unnoticed because the lane's init runs `--local`, which registers no statusline and therefore registers no hook whose target is missing. A `--global` install on the same tarball exits 1 on #4249's own gate (`gsd-statusline.js (missing)`), which is what the new configured-entrypoint cycle performs, so without this step the cycle would report INIT_FAILED instead of checking anything. Build hooks before packing so the smoked tarball matches prepublishOnly. The CLI now reports 16 configured entrypoints for claude and 1 for codex instead of zero. * fix(#4249): scope Codex's full snapshot restore to entrypoint failures Binding Codex's result to `restoreCodexSnapshot` made ANY finalize-stage exception un-install a Codex install that had already succeeded and already printed its own "Done!" summary — `rollbackFinalizedInstallerMigrations` wraps the whole `finalize()` body, not just the aggregate `assertConfiguredEntrypoints` call. Nothing documents that. `docs/installer-migrations.md#phase-4-installupdate-integration` scopes finalize-stage rollback to installer *migrations* ("the executor uses the journal to restore modified paths"), and this PR's own operator-facing paragraph in `docs/how-to/update-gsd.md` scopes the Codex config.toml/hooks.json/skills/ agents/VERSION revert to entrypoint-validation failures specifically ("If a script is missing, unreadable, ... For Codex, this reverts ..."). The wide behaviour is also incoherent as a transaction abort: the same doc says Cursor, Windsurf, Kimi and Cline keep the config they wrote inside install(). Concretely: `installAllRuntimes(['codex', 'kilo'])` where Kilo's finishInstall hits EACCES writing kilo.json rolled Codex's config.toml back to its pre-install bytes — on an update, silently downgrading a working Codex install to the previous version while the user had just been told it was Done. Select the rollback by error kind instead. `assertConfiguredEntrypoints` already tags its error with `configuredEntrypointValidation`, so the full snapshot restore runs for that error (and anything downstream of it, including finishInstall's per-runtime backstop) and the installer-migrations-only closure runs for everything else. The codex result now also exposes that narrow closure as `rollbackInstallerMigrationsOnly`; `rollbackInstallerMigrations` keeps meaning the full restore, so the direct-call contract asserted by tests/codex-config.test.cjs is unchanged. Adds a regression test that installs codex+kilo together, injects EACCES on the Kilo permission write by monkeypatching node:fs (restored in a finally — never chmod 0o000, which root bypasses in CI), and asserts Codex's config.toml keeps the bytes the successful install wrote. Verified red against the pre-fix unconditional path. Cline cannot host this test: its plan is writesSharedSettings:false + finishPermissionWriter:null, so its finishInstall performs no write and has no non-entrypoint failure path. Kilo's configureKiloPermissions runs unconditionally (unlike OpenCode's, it is not GSD_TEST_MODE-gated) and ends in an unguarded fs.writeFileSync. * docs(#4249): sync CONTEXT.md's rollback description with the round-6 narrowing CONTEXT.md still described Codex's rollback as an unconditional bind to restoreCodexSnapshot after ff13adc00 scoped it to entrypoint- validation failures via rollbackInstallerMigrationsOnly and the configuredEntrypointValidation error tag. Caught during the round-6 PR body pass. * fix(#4249): stop rollbackInstallerMigrations meaning its own opposite Codex's install() result bound `rollbackInstallerMigrations` to restoreCodexSnapshot (the FULL pre-install snapshot restore) and put the actual installer-migrations-only closure behind `rollbackInstallerMigrationsOnly` — so for one runtime the unsuffixed name meant the opposite of what it says, and CONTEXT.md had to concede as much in prose. Invert it: `rollbackInstallerMigrations` is the narrow closure for every runtime, matching both its name and the meaning it already has on next, and the snapshot restore gets its own Codex-only field, `rollbackPreInstallSnapshot`. The selection in rollbackFinalizedInstallerMigrations collapses to one line and no longer needs a fallback chain. Also in this commit, all against the same rollback path: - Correct the rollbackFinalizedInstallerMigrations comment. It read as if the round-6 narrowing prevented any sibling-triggered revert of a Codex install the user has already seen "Done!" for. It does not, and is not meant to: `wide` is true for ANY entrypoint-validation error from ANY runtime, because the aggregate gate is all-or-nothing — an invalid Cline entrypoint reverts Codex's snapshot, which tests/configured-entrypoint-validation.test.cjs's 'an aggregate entrypoint validation failure rolls the Codex install back (#4249)' asserts directly. The discriminator is the error's KIND, not which runtime owns the failing path. Comment and CONTEXT.md now say that. - Name the runtime in the "Configured entrypoint validation failed" error. ConfiguredEntrypointInvalid already carries `runtime`; the message threw it away, leaving an operator of a multi-runtime install unable to tell whose entrypoint broke — which matters precisely because the failure can revert a runtime that was itself fine. - Set `configuredEntrypoints: []` explicitly on the copilot-instructions early return. Every other branch states the key; this one relied on installAllRuntimes' `(result.configuredEntrypoints || [])` defence. `[]` is correct, not a workaround: every Copilot hook is an inline printf one-liner (GSD_COPILOT_*_HOOK_BASH/PWSH), so there is no GSD-managed script or interpreter to resolve. No behaviour change beyond the error-message text. * docs(#4249): narrow the smoke scan's config-surface claim to what it checks RUNTIME_CONFIG_FILES claimed every GSD-managed executable a runtime is told to launch is registered in one of settings.json / hooks.json / config.toml, and that nothing else in a config dir is runtime configuration. Both halves are false as stated. Cline registers its hook at .clinerules/hooks/PreToolUse — a subdirectory, and not one of those names (writeClineArtifacts, src/runtime-hooks-surface.cts). Kimi's native [[hooks]] config.toml lives under resolveKimiHooksTomlDir() (~/.kimi), a directory separate from Kimi's own GSD configDir — the same gap installer-migration 007 already documents as structurally unreachable. The scan is in fact correct for what it runs against: entrypointRuntimes defaults to claude + codex, whose launch paths do all live in those three top-level files. Restate the docstring at that scope, name the two known out-of-scope surfaces, and warn that adding either runtime to entrypointRuntimes without teaching scanConfiguredEntrypoints about its surface yields a scan that finds zero entrypoints and proves nothing. The entrypointRuntimes default comment carried the same overgeneralization ("every other runtime reuses one of them") and is corrected with it. Documentation only; no code change. * fix(#4249): complete configuredEntrypoints/rollback shape on unparseable settings.local.json An internal adversarial review (agy/gemini-3.8-flash-medium, round 8) found that install()'s settings-json early return for an unparseable settings.local.json omitted configuredEntrypoints and rollbackInstallerMigrations from its result, unlike every other branch. rollbackFinalizedInstallerMigrations reads result.rollbackInstallerMigrations unconditionally, so this branch silently dropped its own installer-migration rollback on a later finalize-stage failure. Completed the return shape: configuredEntrypoints: [] (matching Copilot's equally-early no-entrypoints-yet return) and rollbackInstallerMigrations (already in closure scope). Red-then-green regression test added. * test(#4249): ensure hooks/dist before packing in release-tarball-smoke.install.test.cjs Same internal adversarial review (round 8): this suite's before() packed the tarball directly, without the ensureHooksDist() guard every sibling install-test suite (install.test.cjs, install-minimal-hooks.test.cjs, mcp-catalog-parity.install.test.cjs) already uses. On a clean tree, or run in isolation ahead of a suite that builds hooks/dist itself, this suite's pack would ship a tarball with no hook scripts and fail closed on SMOKE.INIT_FAILED instead of testing anything. * fix(#4249): refresh stale test-timings weight for the codex-config split next's own consolidation split (#4139/#4540) moved tests/codex-config.test.cjs's heavy install()-pipeline blocks into tests/codex-config-hooks.test.cjs, but the CI shard packer's weight table (tests/test-timings.json) was never updated: codex-config.test.cjs still carried its pre-split weight (127783ms, ~18x the suite mean), and codex-config-hooks.test.cjs — which now holds the #3245 block this PR extends with its own #4249 install()-pipeline test — had no entry at all, so the packer would silently underestimate it at the table's median weight (roughly a 9x underestimate against its real cost). trek-e's most recent review flagged a Windows shard timeout in-flight on codex-config.test.cjs, plausibly aggravated by this PR's own addition to that file before the rebase moved it. Re-measured both files locally (node --test --test-reporter=tap, max of 3 runs, matching the table's own max-across-streams methodology) and patched just these two entries — not a full regeneration, which would need real multi-lane CI data this session doesn't have access to. * fix(#4249): register configured-entrypoint-validation tests in the conformance-tier lists next's platform-conformance-tier classifier (#4591/#4598) landed after this branch's last rebase, so tests/configured-entrypoint-validation.test.cjs and tests/codex-config-hooks.test.cjs were never classified, failing lint:ci's gen-platform-conformance-tier --check and both the Linux and macOS conformance suites. * fix(#4249): drop codex-config.test.cjs from the #4733 pinned isolated-set expectation next's #4733 (landed after this branch's last rebase) replaced the static ISOLATED_HEAVY_FILES set with a threshold derived live from tests/test-timings.json, and pins the current derived result in EXPECTED_ISOLATED_UNIT_FILES for regression coverage. That pinned list still named codex-config.test.cjs, whose own weight this PR already dropped from 127783ms to 189ms (after splitting its heavy install()-pipeline blocks into codex-config-hooks.test.cjs) — well under #4733's derived 120000ms bar. The live-computed set correctly no longer includes it; the pinned expectation is updated to match. * fix(#4249): name the rollback consequence in the entrypoint-validation error, and prove Cline's file survives it trek-e's review flagged two Major gaps: the thrown error read identically regardless of which of three real outcomes a runtime hit (nothing persisted / snapshot reverted / config left broken on disk), and no test proved the disclosed "left on disk, unreverted" case for Cursor/Windsurf/ Kimi/Cline — only Codex's revert path was ever asserted. assertConfiguredEntrypoints now tags each invalid entry with its actual consequence, mirrored from docs/how-to/update-gsd.md's existing rollback-matrix disclosure. A new test drives the same aggregate failure through Cline (whose own entrypoint is the one that fails) and asserts its hook file is still on disk afterward. * fix(#4249): close 4 gaps antigravity's adversarial review found in the entrypoint-validation PR One review pass (gemini-3.8-flash-high via the antigravity review lane) against this PR's full diff against next, findings independently verified against source before fixing: - Copilot's install() return object was the only one of 6 runtime branches missing rollbackInstallerMigrations — reachable now that this PR's own aggregate gate runs rollback across every result on any runtime's entrypoint failure, not just Copilot's own. - buildHookCommand's unresolved-bash early return skipped track() entirely, so a win32 install with no Git Bash silently produced an unregistered .sh hook instead of the 'unresolved-interpreter' validation failure configuredEntrypointsForHook's own comment said it would. - release-tarball-smoke.cjs reported a Cycle 4 install failure under SMOKE.INIT_FAILED (Cycle 1's code) instead of the already-existing SMOKE.INSTALL_FAILED. - SCRIPT_PATH_RE excluded whitespace to avoid swallowing a shell command's trailing args, which also truncated any configDir containing a space (e.g. a real "/Users/John Doe/.claude"), silently zeroing the scan. Anchored the match on the already-known configDir prefix instead of a generic absolute-path guess: removes the ambiguity outright rather than patching the character class, and stays a raw-text scan on purpose (it catches a writer that emits a path without registering it — a JSON.parse of the expected schema would miss exactly that case). One suggested finding (test-timings.json "missing" the new test file) was verified false — that table only holds measured CI timings, populated after a file's first real run — and one Ponytail suggestion (a JSON.stringify dedup key) was rejected as it would reintroduce a real, if narrow, key-collision risk for no benefit. * fix(#4249): fix fork CI red from a stale changeset pr field and an unquoted docs/ comment changeset-lint requires pr: to match the PR it runs on (16 on the fork, not the eventual upstream number) — rehearsal-branch convention already established earlier in this PR's history. lint-docs-guard-registration's quote-pairing heuristic doesn't require the docs/ path itself to be quoted — it flags a file once ANY quote-delimited span containing "docs/" appears anywhere in it, alongside any real fs read call. A comment ending "...update-gsd.md's rollback-matrix paragraph" supplied the closing quote character (the possessive apostrophe) the heuristic paired with an unrelated single-quoted string earlier in the file. Reworded to avoid the unquoted apostrophe next to the path. * chore(#4249): point the changeset pr field back at the upstream PR Fork rehearsal (PR #16) is green; the real target for this changeset is upstream PR #4249. --------- Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
4036 lines
168 KiB
JavaScript
4036 lines
168 KiB
JavaScript
/**
|
|
* GSD Tools Tests - codex-config.cjs
|
|
*
|
|
* Tests for Codex adapter header, agent conversion, config.toml generation/merge,
|
|
* per-agent .toml generation, and uninstall cleanup.
|
|
*/
|
|
|
|
// Enable test exports from install.js (skips main CLI logic)
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test: _test, describe: _describe, before, beforeEach: _beforeEach, afterEach: _afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const _os = require('os');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { cleanup: _cleanup } = require('./helpers.cjs');
|
|
const _fc = require('fast-check');
|
|
const { CLAUDE_AGENT_ALIASES: _CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs');
|
|
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
|
// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel
|
|
// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam
|
|
// decision"). Neither export exists on model-catalog.cjs yet; requiring the
|
|
// module does not throw (it just has no such keys today), but calling
|
|
// isAnthropicFlavoredModel does — see the new describe block below.
|
|
const _modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs');
|
|
const _modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
|
|
|
|
// #2153 follow-up: ensure hooks/dist/ exists before any install integration
|
|
// test runs. The Codex install path copies hook files from hooks/dist/, which
|
|
// is gitignored and only populated by `npm run build:hooks`. When one of the
|
|
// codex-config*.test.cjs files is run in isolation (`node --test
|
|
// tests/codex-config-agents.test.cjs`, for example) the build step from the
|
|
// npm-test pretest chain does not run, and the "Codex install copies hook
|
|
// file" regression silently fails because hooks/dist/ is empty.
|
|
// Build on demand so the test passes regardless of runner ordering.
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
|
// syntax-checks them with vm — it does not compile/bundle anything. See
|
|
// tests/helpers/timeouts.cjs for the class-norm justification.
|
|
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
before(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
throwIfFailed(
|
|
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }),
|
|
`node ${BUILD_HOOKS_SCRIPT}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
const {
|
|
getCodexSkillAdapterHeader: _getCodexSkillAdapterHeader,
|
|
convertClaudeAgentToCodexAgent: _convertClaudeAgentToCodexAgent,
|
|
convertClaudeCommandToCodexSkill: _convertClaudeCommandToCodexSkill,
|
|
generateCodexAgentToml: _generateCodexAgentToml,
|
|
_resetCodexWarningDedupeForTests: __resetCodexWarningDedupeForTests,
|
|
cleanupCodexSkillMetadataSidecars: _cleanupCodexSkillMetadataSidecars,
|
|
generateCodexConfigBlock: _generateCodexConfigBlock,
|
|
stripGsdFromCodexConfig: _stripGsdFromCodexConfig,
|
|
migrateCodexHooksMapFormat: _migrateCodexHooksMapFormat,
|
|
mergeCodexConfig: _mergeCodexConfig,
|
|
install,
|
|
GSD_CODEX_MARKER: _GSD_CODEX_MARKER,
|
|
deriveCodexSandboxMode: _deriveCodexSandboxMode,
|
|
// #3897 rung 3 (ADR-3473 §8.3, option 2 — HALT.md): anticipated new export
|
|
// holding the 17 explicit read-only pins for roles whose tool contract would
|
|
// otherwise derive workspace-write (16 measured by HALT.md + gsd-nyquist-auditor,
|
|
// surfaced by the list-form parse fix). Does not exist on the current tree —
|
|
// destructuring a non-existent key is `undefined`, not a throw, so requiring
|
|
// this module still succeeds; every test below that touches it fails on its
|
|
// own `typeof` guard instead.
|
|
CODEX_SANDBOX_HOLDS: _CODEX_SANDBOX_HOLDS,
|
|
parseTomlToObject: _parseTomlToObject,
|
|
validateCodexConfigSchema: _validateCodexConfigSchema,
|
|
uninstall: _uninstall,
|
|
CODEX_EXTENDED_HOOK_EVENTS: _CODEX_EXTENDED_HOOK_EVENTS,
|
|
} = require('../bin/install.js');
|
|
|
|
const { resolveNodeRunner: _resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const { resolveInstallPlan: _resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
// #3897 fixup: deriveCodexSandboxMode's 2nd param is now the already-resolved
|
|
// `tools:` frontmatter VALUE, not raw agent content (codex-agent-toml.cjs no
|
|
// longer parses frontmatter at all — no third copy of that extraction).
|
|
const {
|
|
extractFrontmatterAndBody: _extractFrontmatterAndBody,
|
|
extractFrontmatterField: _extractFrontmatterField,
|
|
} = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
|
// #3897 list-form parse fix: the ONE shared `tools:`-value reader both
|
|
// sandbox-feeding production paths (`bin/install.js`'s `generateCodexAgentToml`
|
|
// and `agent-install-check.cts`'s `checkCodexSandboxPosture`) now route
|
|
// through — handles inline (`tools: Read, Write`) AND YAML block-list
|
|
// (`tools:` + indented `- Item` lines) form. Used below by `realAgentToolsRaw`
|
|
// so the test's own measurement of "what does this role's tool contract
|
|
// declare" cannot silently disagree with production (the exact generative-
|
|
// fix-divergence shape this fix closes).
|
|
const { extractToolsValue: _extractToolsValue } = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
|
|
|
function _runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
|
const previousCodeHome = process.env.CODEX_HOME;
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
const previousCwd = process.cwd();
|
|
process.env.CODEX_HOME = codexHome;
|
|
// #2088: Codex skills now install to the canonical $HOME/.agents/skills root
|
|
// (os.homedir()-relative, independent of CODEX_HOME — per codex core-skills
|
|
// loader.rs). Sandbox HOME to codexHome so skills land under the temp dir
|
|
// (codexHome/.agents/skills) instead of polluting the developer's real home.
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = 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;
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
// #2088: the canonical Codex skill-install root, sandboxed under codexHome.
|
|
function codexSkillsRoot(codexHome) {
|
|
return path.join(codexHome, '.agents', 'skills');
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
function _readHooksSessionStartCommands(codexHome) {
|
|
const hooksPath = path.join(codexHome, 'hooks.json');
|
|
if (!fs.existsSync(hooksPath)) return [];
|
|
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
|
? parsed.hooks
|
|
: parsed;
|
|
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
|
return sessionStart.flatMap((entry) => [
|
|
...(typeof entry?.command === 'string' ? [entry.command] : []),
|
|
...(Array.isArray(entry?.hooks)
|
|
? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string')
|
|
: []),
|
|
]);
|
|
}
|
|
|
|
function _countMatches(content, pattern) {
|
|
return (content.match(pattern) || []).length;
|
|
}
|
|
|
|
function _assertNoDraftRootKeys(content) {
|
|
assert.ok(!content.includes('model = "gpt-5.6-terra"'), 'does not inject draft model default');
|
|
assert.ok(!content.includes('model_reasoning_effort = "high"'), 'does not inject draft reasoning default');
|
|
assert.ok(!content.includes('disable_response_storage = true'), 'does not inject draft storage default');
|
|
}
|
|
|
|
function _assertUsesOnlyEol(content, eol) {
|
|
if (eol === '\r\n') {
|
|
assert.ok(content.includes('\r\n'), 'contains CRLF line endings');
|
|
assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings');
|
|
return;
|
|
}
|
|
assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings');
|
|
}
|
|
|
|
function _assertNoCodexBareGsdToolsInvocation(content, label) {
|
|
const patterns = [
|
|
/(^|\r?\n)[ \t]*gsd-tools\s/,
|
|
/\$\(\s*gsd-tools\s/,
|
|
/`\s*gsd-tools\s/,
|
|
/(?:&&|\|\||[;|])\s*gsd-tools\s/,
|
|
];
|
|
for (const pattern of patterns) {
|
|
assert.doesNotMatch(
|
|
content,
|
|
pattern,
|
|
`${label} must not contain a command-position bare gsd-tools invocation`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── getCodexSkillAdapterHeader ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2760-codex-install-defensive.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2760-codex-install-defensive (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* 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,
|
|
parseTomlToObject,
|
|
} = require('../bin/install.js');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
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();
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
|
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
|
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
|
// in-process install never materializes skills under the developer/CI
|
|
// machine's real home directory.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = 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;
|
|
}
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
function readCodexHooksJson(codexHome) {
|
|
const hooksPath = path.join(codexHome, 'hooks.json');
|
|
if (!fs.existsSync(hooksPath)) return {};
|
|
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
|
|
if (!raw) return {};
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
function readHooksSessionStartCommands(codexHome) {
|
|
const parsed = readCodexHooksJson(codexHome);
|
|
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
|
? parsed.hooks
|
|
: parsed;
|
|
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
|
return sessionStart.flatMap((entry) =>
|
|
(Array.isArray(entry?.hooks) ? entry.hooks : [])
|
|
.map((hook) => hook && hook.command)
|
|
.filter((cmd) => typeof cmd === 'string')
|
|
);
|
|
}
|
|
|
|
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(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('fresh install emits the two-level nested AoT schema (#2773)', () => {
|
|
// Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]]
|
|
// with type = "command". Neither the flat [[hooks]] + event field form nor
|
|
// the single-block [[hooks.SessionStart]] form without .hooks is accepted.
|
|
writeCodexConfig(codexHome, '');
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(content);
|
|
|
|
const sessionStartCommands = readHooksSessionStartCommands(codexHome);
|
|
const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
|
assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command');
|
|
assert.ok(
|
|
!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart),
|
|
'config.toml should not carry managed SessionStart hooks for GSD'
|
|
);
|
|
});
|
|
|
|
test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => {
|
|
// Users may have their own [[hooks.SessionStart]] entries using the new schema.
|
|
// GSD must append its own two-level block without disturbing theirs.
|
|
const userConfig = [
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "echo first user hook"',
|
|
'',
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "echo second user hook"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, userConfig);
|
|
|
|
runCodexInstall(codexHome);
|
|
const afterInstall = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(afterInstall);
|
|
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
|
'hooks.SessionStart must remain an array-of-tables after install'
|
|
);
|
|
|
|
// Collect all handler commands across all event entries.
|
|
const allCommands = parsed.hooks.SessionStart.flatMap((entry) =>
|
|
Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []
|
|
);
|
|
|
|
assert.ok(
|
|
allCommands.includes('echo first user hook'),
|
|
'first user hook preserved: ' + JSON.stringify(allCommands)
|
|
);
|
|
assert.ok(
|
|
allCommands.includes('echo second user hook'),
|
|
'second user hook preserved: ' + JSON.stringify(allCommands)
|
|
);
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
assert.ok(
|
|
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
|
'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands)
|
|
);
|
|
assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries');
|
|
});
|
|
|
|
test('reinstall replaces flat [[hooks]] + event form with nested schema', () => {
|
|
// Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form).
|
|
const legacyConfig = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "node /old/path/to/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, legacyConfig);
|
|
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
const parsed = parseTomlToObject(content);
|
|
|
|
// Old flat form must be gone.
|
|
assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade');
|
|
// Only one GSD hook entry must exist (no duplication) in hooks.json.
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
|
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade');
|
|
});
|
|
|
|
test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => {
|
|
// Upgrade path: user has a config written by the PR #2802 shape —
|
|
// [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table.
|
|
const prBranchConfig = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks.SessionStart]]',
|
|
'command = "node /old/path/to/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, prBranchConfig);
|
|
|
|
runCodexInstall(codexHome);
|
|
const content = readCodexConfig(codexHome);
|
|
parseTomlToObject(content);
|
|
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
|
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape');
|
|
});
|
|
|
|
test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => {
|
|
writeCodexConfig(codexHome, '');
|
|
runCodexInstall(codexHome);
|
|
runCodexInstall(codexHome); // second install
|
|
readCodexConfig(codexHome);
|
|
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
|
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install');
|
|
});
|
|
});
|
|
|
|
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(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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 + re-emit, only GSD's own managed
|
|
// AgentsToml scalar (max_depth) remains — #2406 stopped emitting
|
|
// [agents.<name>] role sub-tables entirely, so `agents` stays a flat
|
|
// scalar-only object, not a table-of-tables.
|
|
assert.ok(
|
|
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
|
'agents must be an object 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');
|
|
assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key');
|
|
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
|
assert.deepStrictEqual(
|
|
gsdAgents, [],
|
|
'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + 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 plain object holding only GSD's own managed max_depth scalar (#2406
|
|
// stopped emitting [agents.<name>] role sub-tables entirely).
|
|
assert.ok(
|
|
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
|
'agents must be an object in parsed structure (sequence form must be stripped), got: '
|
|
+ (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents)
|
|
);
|
|
assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key');
|
|
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
|
assert.deepStrictEqual(
|
|
gsdAgents, [],
|
|
'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + 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 {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
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;
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
});
|
|
|
|
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;
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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('^' + escapeRegex(configPath) + '\\.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('^' + escapeRegex(configPath) + '\\.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(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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')
|
|
);
|
|
|
|
// Migration now handles stale [[hooks.SessionStart]] entries with handler
|
|
// fields at event-entry level (pre-#2773 shape), promoting them to the
|
|
// two-level nested form. Every entry must carry a .hooks sub-array after
|
|
// migration, so collect from nested handlers only.
|
|
assert.ok(
|
|
parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)),
|
|
'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration'
|
|
);
|
|
const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) =>
|
|
entry.hooks.map((h) => h.command).filter(Boolean)
|
|
);
|
|
assert.ok(
|
|
allSessionStartCommands.includes('echo user hook'),
|
|
'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands)
|
|
);
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
assert.ok(
|
|
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
|
'GSD entry must appear in hooks.json SessionStart entries: '
|
|
+ JSON.stringify(hooksJsonCommands)
|
|
);
|
|
|
|
// 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 = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd));
|
|
assert.equal(gsdEntries.length, 1,
|
|
'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length);
|
|
});
|
|
});
|
|
|
|
describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => {
|
|
// #3245 inverts the float-rejection requirement: Codex CLI's serde schema
|
|
// requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser
|
|
// must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was
|
|
// "don't silently truncate 0.5 to integer 0" — that goal is still met
|
|
// because we parse the full float as a JS Number (not truncate to prefix).
|
|
test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => {
|
|
const content = [
|
|
'[server]',
|
|
'timeout = 0.5',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.server.timeout, 0.5,
|
|
'float values must be accepted as JS Number (not truncated to 0) — #3245');
|
|
});
|
|
|
|
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;
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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 codex_hooks ownership/.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;
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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'
|
|
);
|
|
// AfterTool was in [[hooks.AfterTool]] with command at event-entry level
|
|
// (pre-#2773 stale namespaced AoT shape). Migration now promotes these to
|
|
// the two-level nested form, so every entry must have a .hooks sub-array.
|
|
assert.ok(
|
|
parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)),
|
|
'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration'
|
|
);
|
|
const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) =>
|
|
e.hooks.map((h) => h.command).filter(Boolean)
|
|
);
|
|
assert.ok(
|
|
afterToolCommands.includes('x'),
|
|
'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands)
|
|
);
|
|
|
|
// The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table.
|
|
assert.ok(
|
|
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
|
'migrated SessionStart must be namespaced AoT (not flat [[hooks]])'
|
|
);
|
|
// After migration, [hooks.SessionStart] map-format is promoted to nested AoT.
|
|
// Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema).
|
|
assert.ok(
|
|
parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)),
|
|
'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration'
|
|
);
|
|
const ssCommands = parsed.hooks.SessionStart.flatMap((e) =>
|
|
e.hooks.map((h) => h.command).filter(Boolean)
|
|
);
|
|
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.
|
|
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
|
assert.ok(
|
|
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
|
'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' +
|
|
JSON.stringify(hooksJsonCommands)
|
|
);
|
|
|
|
// 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));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-279-codex-agent-mapping.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-279-codex-agent-mapping (consolidation epic #1969 B1 #1970)", () => {
|
|
'use strict';
|
|
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
|
|
|
describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => {
|
|
test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => {
|
|
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
assert.ok(
|
|
/Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
/Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent',
|
|
);
|
|
});
|
|
|
|
test('adapter includes deferred tool_search discovery guidance before inline fallback', () => {
|
|
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
assert.ok(
|
|
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
|
'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3017-codex-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3017-codex-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => {
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* Bug #3017: Codex SessionStart hook still emits bare `node` after #3002.
|
|
*
|
|
* PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude
|
|
* Code, Gemini, Antigravity) by routing through buildHookCommand() →
|
|
* resolveNodeRunner(), which emits the absolute Node binary path. But the
|
|
* Codex install path writes its SessionStart hook directly into a
|
|
* config.toml string, bypassing both helpers:
|
|
*
|
|
* command = "node ${updateCheckScript}"
|
|
*
|
|
* Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node
|
|
* is not resolvable, the hook fails with `/bin/sh: node: command not
|
|
* found` (exit 127). The same failure mode #2979 was meant to fix —
|
|
* just on the codex toml branch instead of the settings.json branch.
|
|
*
|
|
* The fix exposes two pure helpers and tests them as typed records,
|
|
* not by grepping install.js content:
|
|
*
|
|
* buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string
|
|
* - emits `command = "<absoluteRunner> <quoted hook path>"` so the
|
|
* hook resolves under minimal PATH.
|
|
* - returns null when absoluteRunner is null (caller skips with warn,
|
|
* matching settings.json branch behavior).
|
|
*
|
|
* rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed }
|
|
* - rewrites an existing bare-node managed-hook command on reinstall
|
|
* (matches the rewriteLegacyManagedNodeHookCommands shape from #3002).
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
|
|
const HOOKS_SURFACE = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs'));
|
|
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
|
const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = HOOKS_SURFACE;
|
|
const { projectCodexHookTomlCommand } = projection;
|
|
|
|
/**
|
|
* Parse the toml hook block into a typed record so tests can assert on
|
|
* the structured shape (what's the runner, what's the hook path, what's
|
|
* the type) rather than substring-matching the toml text.
|
|
*/
|
|
function parseCodexHookBlock(block) {
|
|
if (!block) return { ok: false, reason: 'empty' };
|
|
// The block always carries the "# GSD Hooks" marker, the AoT tables,
|
|
// a type=command, and a command="<runner> <quoted-hook-path>" line.
|
|
const hasMarker = /^# GSD Hooks$/m.test(block);
|
|
const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block);
|
|
const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block);
|
|
const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m);
|
|
// command = "<runner> <hookpath>" — runner may itself be a quoted absolute path.
|
|
// Match the whole RHS as one toml double-quoted string, then split into runner + hookpath.
|
|
const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m);
|
|
if (!cmdLine) return { ok: false, reason: 'no command line' };
|
|
const cmdValue = cmdLine[1];
|
|
// Inside the command value, the runner is either a quoted string (escaped \" in toml)
|
|
// or a bare token, followed by a space and the hook path (quoted).
|
|
// toml escapes interior " as \", so the cmdValue contains literal \" sequences.
|
|
const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/);
|
|
return {
|
|
ok: true,
|
|
hasMarker,
|
|
hasEvent,
|
|
hasHandler,
|
|
type: typeMatch ? typeMatch[1] : null,
|
|
command: cmdValue,
|
|
runner: cmdParsed ? cmdParsed[1] : null,
|
|
hookPath: cmdParsed ? cmdParsed[2] : null,
|
|
};
|
|
}
|
|
|
|
// Strip the toml-escape (\") and JSON-quote (") layers from the parsed
|
|
// runner token to compare against the raw absolute path the caller
|
|
// supplied. parsed.runner round-trips through TWO escape layers:
|
|
// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes
|
|
// 2. toml escapes the interior " to \" inside the command field
|
|
// After both, parsed.runner ends in `\"` and starts with `\"`.
|
|
function unescapeRunner(token) {
|
|
if (!token) return token;
|
|
let t = token.replace(/^\\"/, '').replace(/\\"$/, '');
|
|
if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1);
|
|
return t;
|
|
}
|
|
|
|
describe('Bug #3017 / #3440: Codex hook projection seam', () => {
|
|
test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => {
|
|
const commandValue = projectCodexHookTomlCommand({
|
|
absoluteRunner: '"/usr/local/bin/node"',
|
|
scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js',
|
|
platform: 'linux',
|
|
});
|
|
assert.equal(
|
|
commandValue,
|
|
'\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => {
|
|
test('exported as a function', () => {
|
|
assert.equal(typeof buildCodexHookBlock, 'function');
|
|
});
|
|
|
|
test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => {
|
|
const targetDir = '/tmp/codex-test/.codex';
|
|
const expectedRunnerPath = '/usr/local/bin/node';
|
|
const absoluteRunner = `"${expectedRunnerPath}"`;
|
|
const block = buildCodexHookBlock(targetDir, { absoluteRunner });
|
|
const parsed = parseCodexHookBlock(block);
|
|
assert.equal(parsed.ok, true, `parse failed: ${block}`);
|
|
assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present');
|
|
assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present');
|
|
assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present');
|
|
assert.equal(parsed.type, 'command', 'handler is type=command');
|
|
// Strict: parsed runner must match the supplied absolute path EXACTLY
|
|
// (after stripping toml/JSON escape layers). A loose substring like
|
|
// '/node' would let an unrelated absolute token containing '/node'
|
|
// pass — e.g. '/Users/x/notnode/foo'.
|
|
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
|
`parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`);
|
|
// On Windows, path.resolve prepends the current drive letter ("D:") to
|
|
// the POSIX-shaped fixture path. Accept either form.
|
|
const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js';
|
|
assert.ok(
|
|
parsed.hookPath === expectedHookSuffix ||
|
|
parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix,
|
|
`hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`,
|
|
);
|
|
});
|
|
|
|
test('returns null when absoluteRunner is null (caller skips registration)', () => {
|
|
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null });
|
|
assert.equal(block, null,
|
|
'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook');
|
|
});
|
|
|
|
test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => {
|
|
const runner = resolveNodeRunner();
|
|
assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env');
|
|
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner });
|
|
const parsed = parseCodexHookBlock(block);
|
|
assert.equal(parsed.ok, true);
|
|
// Strict canonical-runner equality: the parsed runner (after stripping
|
|
// toml + JSON escape layers) must be exactly the normalized runner that
|
|
// resolveNodeRunner selected. Homebrew Cellar execPath values intentionally
|
|
// normalize to the stable Homebrew symlink (#3181).
|
|
const expected = JSON.parse(runner);
|
|
assert.equal(unescapeRunner(parsed.runner), expected,
|
|
`parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`);
|
|
});
|
|
});
|
|
|
|
describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => {
|
|
test('exported as a function', () => {
|
|
assert.equal(typeof rewriteLegacyCodexHookBlock, 'function');
|
|
});
|
|
|
|
test('rewrites a bare-node managed-hook command to the absolute runner', () => {
|
|
const before = [
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "node /Users/x/.codex/hooks/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
const expectedRunnerPath = '/usr/local/bin/node';
|
|
const runner = `"${expectedRunnerPath}"`;
|
|
const result = rewriteLegacyCodexHookBlock(before, runner);
|
|
assert.equal(result.changed, true, 'must report change=true');
|
|
// The migrated command must use the EXACT absolute runner the caller
|
|
// supplied (#3022 CR — was previously asserting a loose '/node'
|
|
// substring which let unrelated absolute paths pass).
|
|
const parsed = parseCodexHookBlock(result.content);
|
|
assert.equal(parsed.ok, true);
|
|
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
|
`runner must equal supplied absolute path: ${parsed.runner}`);
|
|
assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js');
|
|
// Non-GSD content (the [model] block) must be preserved verbatim.
|
|
assert.ok(result.content.includes('[model]'));
|
|
assert.ok(result.content.includes('name = "o3"'));
|
|
});
|
|
|
|
test('decodes TOML-escaped quoted script paths before projection', () => {
|
|
const before = [
|
|
'# GSD Hooks',
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""',
|
|
'',
|
|
].join('\n');
|
|
const runner = '"/usr/local/bin/node"';
|
|
const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' });
|
|
assert.equal(result.changed, true);
|
|
const parsed = parseCodexHookBlock(result.content);
|
|
assert.equal(parsed.ok, true, 'hook block must parse correctly');
|
|
const expected = projectCodexHookTomlCommand({
|
|
absoluteRunner: runner,
|
|
scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js',
|
|
platform: 'win32',
|
|
});
|
|
assert.equal(parsed.command, expected,
|
|
'rewritten command must project from decoded Windows path (not TOML-escaped token text)');
|
|
assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node',
|
|
'runner must equal supplied absolute path');
|
|
assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js',
|
|
'hook path must equal decoded Windows path after projection normalization');
|
|
});
|
|
|
|
test('does NOT touch a managed-hook entry that already uses an absolute runner', () => {
|
|
const already = [
|
|
'# GSD Hooks',
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"');
|
|
assert.equal(result.changed, false);
|
|
assert.equal(result.content, already);
|
|
});
|
|
|
|
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
|
|
const userOwned = [
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "node /home/me/my-custom-codex-hook.js"',
|
|
'',
|
|
].join('\n');
|
|
const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"');
|
|
assert.equal(result.changed, false,
|
|
'user-authored hooks must be left alone; only managed gsd-* hooks are migrated');
|
|
assert.equal(result.content, userOwned);
|
|
});
|
|
|
|
test('returns content unchanged when absoluteRunner is null', () => {
|
|
const before = 'command = "node /path/to/gsd-check-update.js"';
|
|
const result = rewriteLegacyCodexHookBlock(before, null);
|
|
assert.equal(result.changed, false);
|
|
assert.equal(result.content, before);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3018-codex-discuss-fallback.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3018-codex-discuss-fallback (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* Regression test for bug #3018.
|
|
*
|
|
* @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where
|
|
* `request_user_input` is rejected) caused the agent to pick "reasonable
|
|
* defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md
|
|
* checkpoints — without ever surfacing the questions to the user. The
|
|
* generated Codex skill adapter explicitly told it to do that:
|
|
*
|
|
* "When `request_user_input` is rejected (Execute mode), present a
|
|
* plain-text numbered list and pick a reasonable default."
|
|
*
|
|
* Discuss-mode is the wrong place for that fallback. The contract should be:
|
|
* stop, render the questions as plain text, wait for the user's answer.
|
|
* Defaults may only be picked when the user has authorized non-interactive
|
|
* mode (--auto / --all) or has explicitly approved them.
|
|
*
|
|
* Test design (#3027 CR follow-up): instead of grepping the prose with
|
|
* regex, parse the fallback section into a typed semantic-flag record and
|
|
* assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep"
|
|
* — the test names a behavioral invariant, the parser walks the prose
|
|
* once and exposes the invariants as named flags, and the prose can be
|
|
* reworded freely as long as the flags stay true.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
|
|
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
|
const { getCodexSkillAdapterHeader } = INSTALL;
|
|
const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
/**
|
|
* Extract the "Execute mode fallback" section text from the adapter header.
|
|
* Returns null if the section is missing. Section runs from the
|
|
* "Execute mode fallback:" label up to the next heading or </codex_skill_adapter> tag.
|
|
*/
|
|
function extractExecuteModeFallback(header) {
|
|
const label = 'Execute mode fallback:';
|
|
const labelIdx = header.indexOf(label);
|
|
if (labelIdx === -1) return null;
|
|
const bodyStart = header.indexOf('\n', labelIdx + label.length);
|
|
if (bodyStart === -1) return null;
|
|
|
|
// End at whichever comes first: the next "## " heading (via the canonical
|
|
// heading tokenizer, not an ad-hoc regex) or the closing adapter tag.
|
|
const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart);
|
|
const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n
|
|
const closeTagIdx = header.indexOf('</codex_skill_adapter>', bodyStart);
|
|
const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n
|
|
const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset);
|
|
if (bodyEnd === Infinity) return null;
|
|
|
|
return header.slice(bodyStart + 1, bodyEnd).trim();
|
|
}
|
|
|
|
/**
|
|
* Parse the Execute-mode-fallback section into a typed semantic-flag
|
|
* record. Each flag answers a single behavioral question that the #3018
|
|
* fix is contractually required to encode in the prose. Tests assert on
|
|
* the booleans, not the wording — so the prose can evolve without test
|
|
* churn as long as the semantics stay correct.
|
|
*
|
|
* The flags are derived from a single pass over the section text: each
|
|
* one looks for any of a small set of synonym phrases that a correct
|
|
* implementation would use. The negative anti-pattern flag
|
|
* (`silentlyPicksDefaults`) is the regression guard — the prose under
|
|
* #3018 told the agent to "pick a reasonable default" autonomously,
|
|
* which is exactly what this fix removes.
|
|
*/
|
|
function parseExecuteModeFallback(section) {
|
|
if (!section || typeof section !== 'string') {
|
|
return {
|
|
ok: false,
|
|
sectionLength: 0,
|
|
instructsStop: false,
|
|
presentsPlainTextQuestions: false,
|
|
namesPermissionPath: false,
|
|
forbidsWritingArtifactsBeforeAnswer: false,
|
|
silentlyPicksDefaults: false,
|
|
};
|
|
}
|
|
const lower = section.toLowerCase();
|
|
// (a) STOP/WAIT directive — the agent must halt instead of proceeding.
|
|
const instructsStop = /\b(stop|halt|wait)\b/.test(lower);
|
|
// (b) Plain-text fallback presentation — the agent must surface the
|
|
// questions in some inspectable form (numbered list / plain text).
|
|
const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower);
|
|
// (c) Permission path that DOES allow defaults — must name at least
|
|
// one (--auto / --all / explicit user approval / autonomous workflow).
|
|
const namesPermissionPath =
|
|
/--auto|--all/.test(section) ||
|
|
/explicit(ly)? (approv|authoriz|consent)/i.test(section) ||
|
|
/user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) ||
|
|
/autonomous (lifecycle|workflow|paths?)/i.test(section);
|
|
// (d) Artifact-write ban — the agent must not produce workflow files
|
|
// (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the
|
|
// user answers or one of the permission-path conditions applies.
|
|
// Require BOTH a "do not write" intent AND a named artifact class so
|
|
// generic "do not write" prose elsewhere can't satisfy the flag.
|
|
const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section);
|
|
const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section);
|
|
const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass;
|
|
// Anti-pattern guard — the prose that caused #3018. This MUST be false.
|
|
const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section);
|
|
return {
|
|
ok: true,
|
|
sectionLength: section.length,
|
|
instructsStop,
|
|
presentsPlainTextQuestions,
|
|
namesPermissionPath,
|
|
forbidsWritingArtifactsBeforeAnswer,
|
|
silentlyPicksDefaults,
|
|
};
|
|
}
|
|
|
|
describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => {
|
|
test('exports the adapter generator', () => {
|
|
assert.equal(typeof getCodexSkillAdapterHeader, 'function');
|
|
});
|
|
|
|
test('Execute mode fallback section exists and has content', () => {
|
|
const header = getCodexSkillAdapterHeader('gsd-discuss-phase');
|
|
const section = extractExecuteModeFallback(header);
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`);
|
|
assert.ok(parsed.sectionLength > 0, 'section must be non-empty');
|
|
});
|
|
|
|
test('fallback instructs STOP/WAIT (not silent continuation)', () => {
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.instructsStop, true,
|
|
`must instruct stop/halt/wait — section was:\n${section}`);
|
|
});
|
|
|
|
test('fallback prescribes plain-text question presentation', () => {
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.presentsPlainTextQuestions, true,
|
|
`must mention plain-text / numbered-list presentation — section was:\n${section}`);
|
|
});
|
|
|
|
test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => {
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.namesPermissionPath, true,
|
|
`must name at least one permission path — section was:\n${section}`);
|
|
});
|
|
|
|
test('fallback forbids writing workflow artifacts before user answers', () => {
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true,
|
|
`must encode write-ban + named artifact class — section was:\n${section}`);
|
|
});
|
|
|
|
test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => {
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
assert.equal(parsed.silentlyPicksDefaults, false,
|
|
`regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`);
|
|
});
|
|
|
|
test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => {
|
|
// Single assertion that the whole semantic record matches the contract.
|
|
// If any flag flips, the test fails with a structured diff naming the
|
|
// exact invariant that broke.
|
|
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
|
const parsed = parseExecuteModeFallback(section);
|
|
const semanticContract = {
|
|
ok: parsed.ok,
|
|
instructsStop: parsed.instructsStop,
|
|
presentsPlainTextQuestions: parsed.presentsPlainTextQuestions,
|
|
namesPermissionPath: parsed.namesPermissionPath,
|
|
forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer,
|
|
silentlyPicksDefaults: parsed.silentlyPicksDefaults,
|
|
};
|
|
assert.deepStrictEqual(semanticContract, {
|
|
ok: true,
|
|
instructsStop: true,
|
|
presentsPlainTextQuestions: true,
|
|
namesPermissionPath: true,
|
|
forbidsWritingArtifactsBeforeAnswer: true,
|
|
silentlyPicksDefaults: false,
|
|
}, `discuss-mode fallback contract violated — section was:\n${section}`);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3245-codex-toml-floats.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3245-codex-toml-floats (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* Regression: issue #3245 — Codex install rejects valid TOML floats.
|
|
*
|
|
* Two defects, two fixes:
|
|
*
|
|
* Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0).
|
|
* Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec
|
|
* (integers fail with "invalid type: integer"). GSD's strict-integer-only parser
|
|
* was the inverse of what Codex requires — any float triggers the rejection branch.
|
|
* Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as
|
|
* JS Number. The merged config.toml preserves the float form verbatim so
|
|
* round-trip writes don't coerce 20.0 → 20.
|
|
*
|
|
* Defect 2 — Partial rollback leaves install in hybrid state.
|
|
* restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION
|
|
* are written earlier in the install sequence. A post-install validation failure
|
|
* aborts with new agent text on disk, config.toml reverted, and .tmp files
|
|
* potentially orphaned.
|
|
* Fix: capture pre-install state of skills/, agents/, and VERSION before any
|
|
* Codex-specific mutation, and extend the rollback to cover all of them.
|
|
*/
|
|
|
|
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
|
// skips the main CLI entry point and exports its internals.
|
|
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js');
|
|
const installModule = require('../bin/install.js');
|
|
|
|
if (previousGsdTestMode === undefined) {
|
|
delete process.env.GSD_TEST_MODE;
|
|
} else {
|
|
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
|
}
|
|
|
|
// Ensure hooks/dist/ is populated — mirrors the shared hooks/dist bootstrap pattern at the top of this file.
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
|
// syntax-checks them with vm — it does not compile/bundle anything. See
|
|
// tests/helpers/timeouts.cjs for the class-norm justification.
|
|
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
before(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
throwIfFailed(
|
|
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
|
`node ${BUILD_HOOKS_SCRIPT}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
function runCodexInstall(codexHome) {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
|
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
|
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
|
// in-process install never materializes skills under the developer/CI
|
|
// machine's real home directory.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = codexHome;
|
|
try {
|
|
process.chdir(path.join(__dirname, '..'));
|
|
return install(true, 'codex');
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousCodexHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = previousCodexHome;
|
|
}
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
function writeCodexConfig(codexHome, content) {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defect 1 — parseTomlValue must accept TOML floats
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3245 — parseTomlToObject accepts TOML floats', () => {
|
|
test('parses bare decimal float (20.0)', () => {
|
|
const content = [
|
|
'tool_timeout_sec = 20.0',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(typeof parsed.tool_timeout_sec, 'number',
|
|
'tool_timeout_sec should be a JS number');
|
|
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
|
'value must equal 20.0');
|
|
});
|
|
|
|
test('parses startup_timeout_sec = 60.0', () => {
|
|
const content = [
|
|
'startup_timeout_sec = 60.0',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.startup_timeout_sec, 60.0);
|
|
});
|
|
|
|
test('parses positive exponent notation (1e10)', () => {
|
|
const content = [
|
|
'x = 1e10',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.x, 1e10);
|
|
});
|
|
|
|
test('parses negative exponent (1.5e-3)', () => {
|
|
const content = [
|
|
'x = 1.5e-3',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3');
|
|
});
|
|
|
|
test('parses signed positive float (+1.0)', () => {
|
|
const content = [
|
|
'x = +1.0',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.x, 1.0);
|
|
});
|
|
|
|
test('parses signed negative float (-0.5)', () => {
|
|
const content = [
|
|
'x = -0.5',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.x, -0.5);
|
|
});
|
|
|
|
test('parses float with underscore separators (1_000.0)', () => {
|
|
const content = [
|
|
'x = 1_000.0',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.x, 1000.0);
|
|
});
|
|
|
|
test('integer (no decimal) still parses as integer', () => {
|
|
const content = [
|
|
'x = 42',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseTomlToObject(content);
|
|
assert.strictEqual(parsed.x, 42);
|
|
});
|
|
|
|
test('still rejects bare date (1979-05-27)', () => {
|
|
const content = [
|
|
'x = 1979-05-27',
|
|
'',
|
|
].join('\n');
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/unsupported TOML value/,
|
|
'date literals must remain unsupported'
|
|
);
|
|
});
|
|
|
|
test('still rejects bare time (07:32:00)', () => {
|
|
const content = [
|
|
'x = 07:32:00',
|
|
'',
|
|
].join('\n');
|
|
// With leading-zero rejection (CR4 fix) the parser stops at `0`, and
|
|
// `7:32:00` is "trailing bytes". Either error form is acceptable — the
|
|
// key invariant is that time literals are never silently accepted.
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/unsupported TOML value|trailing bytes/,
|
|
'time literals must remain unsupported'
|
|
);
|
|
});
|
|
|
|
test('still rejects hex literal (0x1A)', () => {
|
|
const content = [
|
|
'x = 0x1A',
|
|
'',
|
|
].join('\n');
|
|
// 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes"
|
|
// or "unsupported value" depending on where the parser catches it.
|
|
assert.throws(
|
|
() => parseTomlToObject(content),
|
|
/trailing bytes|unsupported (TOML value|value)/,
|
|
'hex literals must remain unsupported'
|
|
);
|
|
});
|
|
|
|
test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => {
|
|
const content = [
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
'tool_timeout_sec = 20.0',
|
|
'startup_timeout_sec = 60.0',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'schema validation must pass for a config containing TOML floats: ' + result.reason);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defect 1 — full install must succeed and preserve float verbatim
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// concurrency: false — drives the live install pipeline (shared CODEX_HOME env,
|
|
// process.chdir). Serialise to prevent stray mutations across parallel siblings.
|
|
describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('install completes when config.toml contains tool_timeout_sec = 20.0', () => {
|
|
// Floats at the root level (before any table header) — this is where Codex
|
|
// CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema.
|
|
const preInstall = [
|
|
'tool_timeout_sec = 20.0',
|
|
'startup_timeout_sec = 60.0',
|
|
'',
|
|
'[model]',
|
|
'name = "o3"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
// Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported".
|
|
assert.doesNotThrow(
|
|
() => runCodexInstall(codexHome),
|
|
'install must not throw when config.toml contains TOML floats'
|
|
);
|
|
|
|
// The merged config.toml must still contain the float values at root scope.
|
|
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
const parsed = parseTomlToObject(after);
|
|
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
|
'tool_timeout_sec must be preserved as a number after install');
|
|
assert.strictEqual(parsed.startup_timeout_sec, 60.0,
|
|
'startup_timeout_sec must be preserved as a number after install');
|
|
});
|
|
|
|
test('post-install config round-trips tool_timeout_sec as numeric 20', () => {
|
|
const preInstall = [
|
|
'tool_timeout_sec = 20.0',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(codexHome, preInstall);
|
|
|
|
runCodexInstall(codexHome);
|
|
|
|
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
// The value must survive round-trip as a float-compatible representation.
|
|
// Parse structurally — don't grep for the literal string "20.0".
|
|
const parsed = parseTomlToObject(after);
|
|
assert.strictEqual(parsed.tool_timeout_sec, 20,
|
|
'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow
|
|
// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0`
|
|
// are therefore invalid. The `parseTomlValue` integer-part regex is tightened
|
|
// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`.
|
|
|
|
describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => {
|
|
function parseValue(raw) {
|
|
// Wrap in a minimal TOML assignment so parseTomlToObject drives the test.
|
|
return parseTomlToObject(`x = ${raw}`).x;
|
|
}
|
|
|
|
function assertRejects(raw, label) {
|
|
let threw = false;
|
|
try { parseValue(raw); } catch (_) { threw = true; }
|
|
assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`);
|
|
}
|
|
|
|
function assertAccepts(raw, expected, label) {
|
|
let val;
|
|
let threw = false;
|
|
try { val = parseValue(raw); } catch (e) { threw = true; }
|
|
assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`);
|
|
if (expected !== undefined) {
|
|
assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`);
|
|
}
|
|
}
|
|
|
|
// --- rejection cases: leading zeros in the integer part ---
|
|
|
|
test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01'));
|
|
test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00'));
|
|
test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5'));
|
|
test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5'));
|
|
test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01'));
|
|
test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01'));
|
|
test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2'));
|
|
test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0'));
|
|
test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0'));
|
|
test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10'));
|
|
|
|
// --- acceptance cases: valid TOML 1.0 numeric forms ---
|
|
|
|
test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero'));
|
|
test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal'));
|
|
test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero'));
|
|
test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1'));
|
|
test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5'));
|
|
test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5'));
|
|
test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1'));
|
|
test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12'));
|
|
test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5'));
|
|
test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000'));
|
|
test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5'));
|
|
test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5'));
|
|
test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0'));
|
|
test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3'));
|
|
test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10'));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Defect 2 — idempotent rollback covers skills, agents, VERSION
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// concurrency: false — patches module.exports.__codexSchemaValidator and drives
|
|
// the install pipeline. Serialise to prevent cross-test pollution.
|
|
describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete installModule.__codexSchemaValidator;
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => {
|
|
// Start from a clean codexHome with no pre-existing GSD content — the dirs
|
|
// do not exist yet. After a failed install they must be absent (or contain
|
|
// only what was there before, i.e. nothing).
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
|
|
// Force schema validation to fail so we can observe the rollback without
|
|
// needing a genuinely broken config.
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'simulated failure for #3245 rollback test',
|
|
});
|
|
|
|
let threw = false;
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (_) {
|
|
threw = true;
|
|
}
|
|
assert.strictEqual(threw, true, 'install must throw when validation fails');
|
|
|
|
// skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback.
|
|
const skillsDir = codexSkillsRoot(codexHome);
|
|
if (fs.existsSync(skillsDir)) {
|
|
const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
|
assert.strictEqual(
|
|
gsdSkills.length,
|
|
0,
|
|
'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ')
|
|
);
|
|
}
|
|
|
|
// agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent.
|
|
// Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest
|
|
// dir and is .toml-inclusive, so its semantics differ from the source roster.
|
|
const agentsDir = path.join(codexHome, 'agents');
|
|
if (fs.existsSync(agentsDir)) {
|
|
const gsdAgents = fs.readdirSync(agentsDir)
|
|
.filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')));
|
|
assert.strictEqual(
|
|
gsdAgents.length,
|
|
0,
|
|
'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ')
|
|
);
|
|
}
|
|
|
|
// VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before).
|
|
const versionPath = path.join(codexHome, 'gsd-core', 'VERSION');
|
|
assert.strictEqual(
|
|
fs.existsSync(versionPath),
|
|
false,
|
|
'rollback must remove the VERSION file written during install'
|
|
);
|
|
});
|
|
|
|
test('rollback is safe when fired before any snapshots were captured (very early failure)', () => {
|
|
// If the validator is injected before ANY install writes happen, the rollback
|
|
// must not throw — it should be idempotent when nothing was written yet.
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'very early simulated failure',
|
|
});
|
|
|
|
// The install must throw (validation failure), but the rollback that runs
|
|
// internally must not throw — it must be idempotent when nothing was written.
|
|
let threw = false;
|
|
try {
|
|
runCodexInstall(codexHome);
|
|
} catch (_) {
|
|
threw = true;
|
|
}
|
|
assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)');
|
|
// Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was
|
|
// created during the install, no gsd-* dirs should survive after rollback.
|
|
const skillsDir = codexSkillsRoot(codexHome);
|
|
const remainingGsdSkills = fs.existsSync(skillsDir)
|
|
? fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
|
|
.map((e) => e.name)
|
|
: [];
|
|
assert.deepStrictEqual(
|
|
remainingGsdSkills,
|
|
[],
|
|
'rollback must remove all gsd-* skill dirs even when fired after minimal writes'
|
|
);
|
|
});
|
|
|
|
test('rollback does not remove pre-existing user skills that GSD did not write', () => {
|
|
// If the user has a custom skill dir (not gsd-*) it must survive rollback.
|
|
const skillsDir = codexSkillsRoot(codexHome);
|
|
const userSkill = path.join(skillsDir, 'my-custom-skill');
|
|
fs.mkdirSync(userSkill, { recursive: true });
|
|
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8');
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'simulated failure — user skill must survive',
|
|
});
|
|
|
|
let threw = false;
|
|
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
|
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)');
|
|
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(userSkill, 'SKILL.md')),
|
|
true,
|
|
'pre-existing non-gsd-* skill must survive rollback'
|
|
);
|
|
});
|
|
|
|
test('rollback removes orphaned atomic-write temp files', () => {
|
|
// Any <file>.tmp-<pid>-<n> files created during aborted atomic writes
|
|
// must be cleaned up by the rollback so targetDir is not left with stray
|
|
// temp files consuming disk space.
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'simulated failure for temp-file cleanup test',
|
|
});
|
|
|
|
let threw = false;
|
|
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
|
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)');
|
|
|
|
// Scan for any *.tmp-* files left in codexHome after rollback.
|
|
const tmpPattern = /\.tmp-\d+-\d+$/;
|
|
function findTmpFiles(dir) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
const results = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...findTmpFiles(full));
|
|
} else if (tmpPattern.test(entry.name)) {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
const stray = findTmpFiles(codexHome);
|
|
assert.strictEqual(
|
|
stray.length,
|
|
0,
|
|
'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ')
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #4249 — install() exposes the full snapshot restore, not just migrations rollback
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const os = require('os');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
const { install } = require('../bin/install.js');
|
|
if (previousGsdTestMode === undefined) {
|
|
delete process.env.GSD_TEST_MODE;
|
|
} else {
|
|
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
|
}
|
|
|
|
// concurrency: false — drives the real install pipeline like the block above.
|
|
describe('#4249 — install() exposes the full snapshot restore, not just migrations rollback', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4249-codex-rollback-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('result.rollbackPreInstallSnapshot() reverts skills/, agents/, and VERSION', () => {
|
|
// Skills resolve $HOME-relative independent of CODEX_HOME (#2088), so the
|
|
// rollback closure must run before this sandboxing is torn down — inline
|
|
// runCodexInstall's env dance instead of using the auto-restoring helper,
|
|
// matching how installAllRuntimes' real aggregate gate calls it: in the
|
|
// same process env install() itself ran in, never after it's restored.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = codexHome;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.chdir(path.join(__dirname, '..'));
|
|
try {
|
|
const result = install(true, 'codex');
|
|
|
|
// A configured-entrypoint validation failure discovered outside install()
|
|
// (installAllRuntimes' aggregate assertConfiguredEntrypoints, run after
|
|
// this function already returned) reaches for this field. Before #4249
|
|
// the only rollback Codex exposed was rollbackInstallerMigrations, which
|
|
// reverts installer-migration state only and leaves the skills/agents/
|
|
// VERSION this successful install just wrote untouched.
|
|
result.rollbackPreInstallSnapshot();
|
|
|
|
const skillsDir = codexSkillsRoot(codexHome);
|
|
const gsdSkills = fs.existsSync(skillsDir)
|
|
? fs.readdirSync(skillsDir, { withFileTypes: true }).filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
|
: [];
|
|
assert.strictEqual(gsdSkills.length, 0, 'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', '));
|
|
|
|
const versionPath = path.join(codexHome, 'gsd-core', 'VERSION');
|
|
assert.strictEqual(fs.existsSync(versionPath), false, 'rollback must remove gsd-core/VERSION');
|
|
|
|
// #4249 (agy adversarial review): the whole point of this describe block
|
|
// is that rollback covers the full pre-install snapshot, not just
|
|
// installer migrations — config.toml/hooks.json must revert too. Both
|
|
// were absent before this fresh install, so rollback must remove them.
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'config.toml')),
|
|
false,
|
|
'rollback must remove config.toml (absent before this fresh install)'
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'hooks.json')),
|
|
false,
|
|
'rollback must remove hooks.json (absent before this fresh install)'
|
|
);
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3285-codex-hooks-state-allowed.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3285-codex-hooks-state-allowed (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* Regression: issue #3285 — Codex install fails when config.toml contains
|
|
* hooks.state entries.
|
|
*
|
|
* Root cause: validateCodexConfigSchema walks every `hooks.*` table section
|
|
* and asserts array-of-tables (AoT) shape, without distinguishing the
|
|
* `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a
|
|
* regular table) from `hooks.<EVENT>` (event handlers like SessionStart,
|
|
* which DO require AoT shape via [[hooks.SessionStart]]).
|
|
*
|
|
* Fix: add a carve-out so that any table whose path starts with `hooks.state`
|
|
* is validated as a regular table (not AoT). All `hooks.<EVENT>` paths still
|
|
* require AoT.
|
|
*/
|
|
|
|
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
|
// skips the main CLI entry point and exports its internals.
|
|
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
|
|
const { validateCodexConfigSchema, install } = require('../bin/install.js');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
if (previousGsdTestMode === undefined) {
|
|
delete process.env.GSD_TEST_MODE;
|
|
} else {
|
|
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
|
}
|
|
|
|
// Ensure hooks/dist/ is populated — mirrors the shared hooks/dist bootstrap pattern at the top of this file.
|
|
const { before, beforeEach, afterEach } = require('node:test');
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
|
// syntax-checks them with vm — it does not compile/bundle anything. See
|
|
// tests/helpers/timeouts.cjs for the class-norm justification.
|
|
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
before(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
throwIfFailed(
|
|
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
|
`node ${BUILD_HOOKS_SCRIPT}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validator unit tests (no install, just validateCodexConfigSchema)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => {
|
|
test('bare [hooks.state] table header passes validation', () => {
|
|
const content = [
|
|
'[hooks.state]',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason);
|
|
});
|
|
|
|
test('bare [hooks.state.<project-key>] table header passes validation', () => {
|
|
// Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries.
|
|
// The key contains slashes and colons — must be quoted in TOML.
|
|
const content = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:abc123"',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'bare [hooks.state.<key>] with trust fields must be allowed: ' + result.reason);
|
|
});
|
|
|
|
test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => {
|
|
// The real-world fixture: user has both Codex trust state AND GSD-managed
|
|
// event hooks in the same config.toml.
|
|
const content = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:abc123"',
|
|
'',
|
|
'[[hooks.SessionStart]]',
|
|
'',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "/usr/local/bin/gsd-check-update"',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason);
|
|
});
|
|
|
|
test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => {
|
|
// Regression guard: the fix must NOT relax AoT requirements for event hooks.
|
|
// [hooks.SessionStart] (single-bracket) must still fail.
|
|
const content = [
|
|
'[hooks.SessionStart]',
|
|
'type = "command"',
|
|
'command = "/some/command"',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, false,
|
|
'[hooks.SessionStart] bare table (not AoT) must still be rejected');
|
|
assert.ok(
|
|
result.reason.includes('hooks.SessionStart'),
|
|
'rejection reason must mention hooks.SessionStart, got: ' + result.reason
|
|
);
|
|
});
|
|
|
|
test('hooks.state object in parsed structure does not trigger non-array rejection', () => {
|
|
// The parsed-object check loops over Object.entries(parsed.hooks) and
|
|
// asserts !Array.isArray(value) → error. hooks.state is an object, not
|
|
// an array. The fix must skip hooks.state in that loop too.
|
|
const content = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'some-key']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:deadbeef"',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason);
|
|
});
|
|
|
|
test('multiple hooks.state sub-keys all pass validation', () => {
|
|
const content = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:aaa"',
|
|
'',
|
|
"[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = false',
|
|
'trusted_hash = "sha256:bbb"',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, true,
|
|
'multiple hooks.state sub-keys must all pass: ' + result.reason);
|
|
});
|
|
|
|
test('[[hooks.state]] AoT form is rejected', () => {
|
|
// hooks.state must be a regular table — array-of-tables shape is invalid.
|
|
const content = [
|
|
'[[hooks.state]]',
|
|
'enabled = true',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, false,
|
|
'[[hooks.state]] (AoT) must be rejected');
|
|
assert.ok(
|
|
result.reason.includes('hooks.state'),
|
|
'rejection reason must mention hooks.state, got: ' + result.reason
|
|
);
|
|
});
|
|
|
|
test('[[hooks.state.foo]] AoT sub-key form is rejected', () => {
|
|
// hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid.
|
|
const content = [
|
|
'[[hooks.state.foo]]',
|
|
'enabled = true',
|
|
'',
|
|
].join('\n');
|
|
const result = validateCodexConfigSchema(content);
|
|
assert.strictEqual(result.ok, false,
|
|
'[[hooks.state.foo]] (AoT sub-key) must be rejected');
|
|
assert.ok(
|
|
result.reason.includes('hooks.state'),
|
|
'rejection reason must mention hooks.state, got: ' + result.reason
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full install integration test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
function writeCodexConfig(content) {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
|
}
|
|
|
|
function runCodexInstall() {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
|
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
|
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to tmpDir so this
|
|
// in-process install never materializes skills under the developer/CI
|
|
// machine's real home directory.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = tmpDir;
|
|
process.env.USERPROFILE = tmpDir;
|
|
try {
|
|
process.chdir(path.join(__dirname, '..'));
|
|
return install(true, 'codex');
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousCodexHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = previousCodexHome;
|
|
}
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-'));
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('install does not throw when config.toml contains hooks.state trust entries', () => {
|
|
// This is the exact failure scenario reported in #3285.
|
|
const preInstall = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:abc123def456"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(preInstall);
|
|
|
|
assert.doesNotThrow(
|
|
() => runCodexInstall(),
|
|
'install must not throw when config.toml contains hooks.state trust entries'
|
|
);
|
|
});
|
|
|
|
test('hooks.state entries are preserved in post-install config.toml', () => {
|
|
const preInstall = [
|
|
'[hooks.state]',
|
|
'',
|
|
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
|
'enabled = true',
|
|
'trusted_hash = "sha256:abc123def456"',
|
|
'',
|
|
].join('\n');
|
|
writeCodexConfig(preInstall);
|
|
|
|
runCodexInstall();
|
|
|
|
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
// Verify structurally: the trust hash key must survive the install.
|
|
// Do NOT grep for the literal string — parse the TOML structure.
|
|
const { parseTomlToObject } = require('../bin/install.js');
|
|
const parsed = parseTomlToObject(after);
|
|
assert.ok(
|
|
parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null,
|
|
'post-install config.toml must have hooks.state as an object'
|
|
);
|
|
// Verify the actual trust entry survives — not just that hooks.state is an object.
|
|
const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0";
|
|
assert.ok(
|
|
parsed.hooks.state[trustKey] != null,
|
|
`post-install must preserve the original trust entry for key: ${trustKey}`
|
|
);
|
|
assert.strictEqual(
|
|
parsed.hooks.state[trustKey].enabled,
|
|
true,
|
|
'preserved trust entry must have enabled = true'
|
|
);
|
|
assert.strictEqual(
|
|
parsed.hooks.state[trustKey].trusted_hash,
|
|
'sha256:abc123def456',
|
|
'preserved trust entry must have the original trusted_hash'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3346-codex-aot-toml-key.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3346-codex-aot-toml-key (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* Regression: issue #3346 — Codex install fails on Windows when the legacy
|
|
* Codex `[hooks]` config uses a `<file>:<event>:<line>:<col>` location tuple
|
|
* as the table key (with the actual event name carried in an `event = "..."`
|
|
* body field). `migrateCodexHooksMapFormat` re-emitted the location tuple
|
|
* verbatim as the leaf TOML key, producing a header like
|
|
*
|
|
* [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]]
|
|
*
|
|
* which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to
|
|
* be the event name, not a diagnostic location identifier).
|
|
*
|
|
* Expected behaviour: when the legacy `[hooks.<X>]` body declares an
|
|
* `event = "..."` field, the migrator must use that event name as the leaf
|
|
* TOML key for the emitted `[[hooks.<EVENT>]]` two-level nested AoT block.
|
|
*
|
|
* Test discipline: parse the migrated TOML with the project's own
|
|
* `parseTomlToObject` and assert on the resulting object shape — never
|
|
* grep the raw string.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
migrateCodexHooksMapFormat,
|
|
parseTomlToObject,
|
|
} = require('../bin/install.js');
|
|
|
|
describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => {
|
|
test('legacy [hooks."<location-tuple>"] with event="..." body migrates to [[hooks.<event>]]', () => {
|
|
// Pre-install fixture: a legacy `[hooks.<quoted-key>]` block whose key is
|
|
// a `<config-path>:<event>:<line>:<col>` location identifier. The actual
|
|
// event name lives in the body as `event = "session_start"`.
|
|
const legacy = [
|
|
'[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]',
|
|
'event = "session_start"',
|
|
'command = "echo hi"',
|
|
'',
|
|
].join('\n');
|
|
|
|
const migrated = migrateCodexHooksMapFormat(legacy);
|
|
const parsed = parseTomlToObject(migrated);
|
|
|
|
// The migrated hooks object must be keyed by the event name, not by the
|
|
// location tuple. This is the core assertion of #3346.
|
|
assert.ok(parsed.hooks, 'migrated TOML must define a hooks table');
|
|
assert.deepEqual(
|
|
Object.keys(parsed.hooks),
|
|
['session_start'],
|
|
`migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}`
|
|
);
|
|
|
|
// The handler body must survive the migration and live under the two-level
|
|
// nested AoT shape (hooks.<event>[0].hooks[0].command).
|
|
const eventEntries = parsed.hooks.session_start;
|
|
assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1,
|
|
'hooks.session_start must be an array of tables');
|
|
const handlers = eventEntries[0].hooks;
|
|
assert.ok(Array.isArray(handlers) && handlers.length >= 1,
|
|
'hooks.session_start[0].hooks must be an array of handler tables');
|
|
assert.equal(handlers[0].command, 'echo hi',
|
|
'handler command must be preserved through migration');
|
|
assert.equal(handlers[0].type, 'command',
|
|
'handler type must default to "command" when no explicit type given');
|
|
assert.equal(handlers[0].event, undefined,
|
|
'handler body must not retain legacy `event` field after migration');
|
|
});
|
|
|
|
test('legacy [hooks."<location>"] with explicit type and event survives migration cleanly', () => {
|
|
// Same as above but with an explicit `type` field — the migrator must not
|
|
// duplicate it when re-emitting the handler.
|
|
const legacy = [
|
|
'[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]',
|
|
'event = "tool_call_pre"',
|
|
'type = "command"',
|
|
'command = "node /path/to/hook.js"',
|
|
'',
|
|
].join('\n');
|
|
|
|
const migrated = migrateCodexHooksMapFormat(legacy);
|
|
const parsed = parseTomlToObject(migrated);
|
|
|
|
assert.deepEqual(
|
|
Object.keys(parsed.hooks),
|
|
['tool_call_pre'],
|
|
'leaf key must be the event name from the `event = "..."` body field'
|
|
);
|
|
const handler = parsed.hooks.tool_call_pre[0].hooks[0];
|
|
assert.equal(handler.command, 'node /path/to/hook.js');
|
|
assert.equal(handler.type, 'command');
|
|
assert.equal(handler.event, undefined,
|
|
'handler body must not retain legacy `event` field after migration');
|
|
});
|
|
|
|
test('legacy [hooks.<bare-event>] without location-tuple key continues to work unchanged', () => {
|
|
// Regression guard: the fix must not break the canonical legacy-map case
|
|
// ([hooks.<event-name>] with handler-fields-only body, no `event` key).
|
|
const legacy = [
|
|
'[hooks.session_start]',
|
|
'command = "echo hi"',
|
|
'',
|
|
].join('\n');
|
|
|
|
const migrated = migrateCodexHooksMapFormat(legacy);
|
|
const parsed = parseTomlToObject(migrated);
|
|
|
|
assert.deepEqual(
|
|
Object.keys(parsed.hooks),
|
|
['session_start'],
|
|
'bare-event legacy shape must continue to migrate to event-named leaf key'
|
|
);
|
|
assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3360-codex-execute-phase-worktrees.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3360-codex-execute-phase-worktrees (consolidation epic #1969 B1 #1970)", () => {
|
|
/**
|
|
* Regression test for bug #3360.
|
|
*
|
|
* Codex does not have a direct equivalent of Claude Code's
|
|
* `Agent(... isolation="worktree")`. The execute-phase workflow must fail
|
|
* closed for Codex + workflow.use_worktrees=true instead of spawning
|
|
* workspace-write executors in the main checkout.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
|
const { getCodexSkillAdapterHeader } = require('../bin/install.js');
|
|
|
|
function parseWorkflowSteps(content) {
|
|
return [...content.matchAll(/<step name="([^"]+)"[^>]*>([\s\S]*?)<\/step>/g)]
|
|
.map((match) => {
|
|
const body = match[2];
|
|
return {
|
|
name: match[1],
|
|
// After #3797 architectural fix, callsites use gsd_run
|
|
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
|
|
// #1521 generalized the guard from Codex-specific to all non-Claude
|
|
// runtimes; #2584 Phase 3 (#2627) generalized it again — off runtime
|
|
// identity entirely and onto the negotiated `dispatch.isolation`
|
|
// capability. The step now resolves ISOLATION (delegating the block to
|
|
// the isolation-dispatch fragment) and fails closed when a host
|
|
// declares no primitive, which is what #3360 actually protects.
|
|
resolvesIsolationCapability: body.includes('Resolve ISOLATION'),
|
|
// Worktree dispatch guidance is no longer a hardcoded Claude flag —
|
|
// step 3 emits the host's DECLARED harness flag.
|
|
worktreeDispatchGuidance: body.includes('{harnessFlag}')
|
|
|| body.includes('executor-isolation-dispatch.md'),
|
|
};
|
|
});
|
|
}
|
|
|
|
function executePhaseWorktreeContract(content) {
|
|
const steps = parseWorkflowSteps(content);
|
|
const initializeIndex = steps.findIndex((step) => step.name === 'initialize');
|
|
const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance);
|
|
assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step');
|
|
assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance');
|
|
|
|
const initialize = steps[initializeIndex];
|
|
return {
|
|
initializeReadsRuntimeConfig: initialize.readsRuntimeConfig,
|
|
initializeResolvesIsolationCapability: initialize.resolvesIsolationCapability,
|
|
guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex,
|
|
};
|
|
}
|
|
|
|
describe('#3360 — execute-phase fails closed for unsupported worktree isolation', () => {
|
|
// #2584 Phase 3 (#2627) moved this from "Codex is blocked by name" to "a host
|
|
// with no declared isolation primitive is blocked". Codex now DECLARES
|
|
// orchestrator-worktree and gets a real isolated path, so the guard can no
|
|
// longer key on its name — but #3360's actual protection (never run executors
|
|
// unisolated against the main checkout) is unchanged and asserted below.
|
|
const ISOLATION_FRAGMENT = path.join(
|
|
ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'executor-isolation-dispatch.md',
|
|
);
|
|
|
|
test('execute-phase resolves the isolation capability before any worktree dispatch', () => {
|
|
const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
|
const contract = executePhaseWorktreeContract(workflow);
|
|
|
|
assert.deepEqual(contract, {
|
|
initializeReadsRuntimeConfig: true,
|
|
initializeResolvesIsolationCapability: true,
|
|
guardStepPrecedesWorktreeDispatch: true,
|
|
});
|
|
});
|
|
|
|
test('a host declaring no isolation primitive still fails closed', () => {
|
|
const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8');
|
|
assert.match(fragment, /ISOLATION="?none"?/,
|
|
'fragment must resolve the none case');
|
|
assert.match(fragment, /FATAL[^\n]*no executor-isolation primitive/,
|
|
'a host with dispatch.isolation=none must fail closed before dispatch (#3360)');
|
|
assert.match(fragment, /use_worktrees=false/,
|
|
'the fail-closed message must tell the user how to proceed');
|
|
});
|
|
|
|
test('the scheduler never gates worktree dispatch on a runtime name', () => {
|
|
const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
|
const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8');
|
|
for (const [label, src] of [['execute-phase.md', workflow], ['isolation fragment', fragment]]) {
|
|
assert.ok(
|
|
!/\[\s*"\$RUNTIME"\s*(?:!=|=)\s*"(?:codex|claude)"\s*\]\s*&&\s*\[\s*"\$USE_WORKTREES"/.test(src),
|
|
`${label}: worktree dispatch must branch on dispatch.isolation, not a runtime name (ADR-1239)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('Codex adapter documents the orchestrator-managed worktree mapping', () => {
|
|
const header = getCodexSkillAdapterHeader('gsd-execute-phase');
|
|
assert.match(header, /isolation="worktree"/);
|
|
assert.match(header, /orchestrator-worktree/i,
|
|
'the adapter header must no longer claim Codex has no worktree mapping — #2584 Phase 3 gave it one');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3426-codex-windows-hooks.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3426-codex-windows-hooks (consolidation epic #1969 B1 #1970)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1
|
|
*
|
|
* After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure
|
|
* mode appeared on v1.42.3+:
|
|
*
|
|
* Failed with non-blocking status code:
|
|
* C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file
|
|
*
|
|
* Root cause: Codex on Windows runs hook commands from a PowerShell/cmd
|
|
* execution environment (see install.js comment at buildHookCommand). The
|
|
* command string written to hooks.json was:
|
|
*
|
|
* "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
|
|
*
|
|
* When Codex's hook runner passes this to its subprocess spawner, the quoted
|
|
* path resolves through Git Bash (MSYS), which then tries to POSIX-exec
|
|
* node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec
|
|
* path calls execvp() on the PE binary directly, which fails with ENOEXEC,
|
|
* reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix
|
|
* appears because the error propagates through the bash.exe process that Codex
|
|
* uses as its hook-dispatch shell.
|
|
*
|
|
* Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple
|
|
* IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command.
|
|
* cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer,
|
|
* no MSYS shebang walk.
|
|
*
|
|
* Test strategy:
|
|
* - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on
|
|
* rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline).
|
|
* - Counter-tests confirm darwin/linux paths are unchanged.
|
|
*
|
|
* NOTE: Windows wall-clock verification depends on Docker matrix Windows
|
|
* runners. Local test exercises the generator IR shape only.
|
|
*/
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const INSTALL = require('../bin/install.js');
|
|
const HOOKS_SURFACE = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
uninstall,
|
|
} = INSTALL;
|
|
|
|
const {
|
|
buildCodexHookWindowsShimIR,
|
|
ensureCodexHooksJsonSessionStart,
|
|
resolveNodeRunner,
|
|
} = HOOKS_SURFACE;
|
|
|
|
const { projectManagedHookCommand } = PROJECTION;
|
|
|
|
/**
|
|
* Extract hook handler objects for `eventName` from a hooks.json object.
|
|
* Handles both the legacy top-level shape { SessionStart: [...] } and the
|
|
* canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348).
|
|
*/
|
|
function hookHandlersForEvent(hooksJson, eventName) {
|
|
if (!hooksJson || typeof hooksJson !== 'object') return [];
|
|
const table =
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks)
|
|
? hooksJson.hooks
|
|
: hooksJson;
|
|
if (!Array.isArray(table[eventName])) return [];
|
|
return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []);
|
|
}
|
|
|
|
// ─── Step 1: Export surface check ────────────────────────────────────────────
|
|
|
|
describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => {
|
|
test('buildCodexHookWindowsShimIR is a function', () => {
|
|
assert.equal(typeof buildCodexHookWindowsShimIR, 'function',
|
|
'buildCodexHookWindowsShimIR must be exported from runtime-hooks-surface.cjs');
|
|
});
|
|
|
|
test('ensureCodexHooksJsonSessionStart is a function', () => {
|
|
assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function',
|
|
'ensureCodexHooksJsonSessionStart must be exported from runtime-hooks-surface.cjs');
|
|
});
|
|
});
|
|
|
|
// ─── Step 2: Typed IR shape for Windows Codex hook shim ──────────────────────
|
|
|
|
describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => {
|
|
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
|
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
|
|
|
test('returns typed IR with invocation, cmdPath, and render factory', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
// IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline
|
|
assert.ok(ir && typeof ir === 'object', 'must return an object');
|
|
assert.ok(typeof ir.invocation === 'object', 'must have invocation record');
|
|
assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string');
|
|
assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)');
|
|
assert.ok(typeof ir.render === 'object', 'must have render factory');
|
|
assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory');
|
|
});
|
|
|
|
test('invocation.target equals the resolved script path', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
// invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple
|
|
assert.ok(
|
|
ir.invocation.target.includes('gsd-check-update.js'),
|
|
`invocation.target must reference the hook script, got: ${ir.invocation.target}`,
|
|
);
|
|
});
|
|
|
|
test('invocation.interpreter is the node runner (not bash)', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
// The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows
|
|
const interp = ir.invocation.interpreter;
|
|
assert.ok(
|
|
typeof interp === 'string' && (interp.includes('node') || interp === 'node'),
|
|
`invocation.interpreter must be a node path, not bash. Got: ${interp}`,
|
|
);
|
|
assert.ok(
|
|
!interp.toLowerCase().includes('bash'),
|
|
`invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`,
|
|
);
|
|
});
|
|
|
|
test('cmdPath ends with .cmd extension', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
assert.ok(
|
|
ir.cmdPath.endsWith('.cmd'),
|
|
`cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`,
|
|
);
|
|
});
|
|
|
|
test('hookCommand is the .cmd path (not a "runner script.js" string)', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
// The hook command written to hooks.json must be the .cmd path, not "node.exe script.js"
|
|
// because cmd.exe executes .cmd natively without POSIX exec layer
|
|
assert.ok(
|
|
ir.hookCommand.includes('.cmd'),
|
|
`hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`,
|
|
);
|
|
// hookCommand must NOT contain bash — this was the failure mode
|
|
assert.ok(
|
|
!ir.hookCommand.toLowerCase().includes('bash'),
|
|
`hookCommand must NOT reference bash, got: ${ir.hookCommand}`,
|
|
);
|
|
});
|
|
|
|
test('returns null when absoluteRunnerToken is null (caller skips registration)', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null);
|
|
assert.equal(ir, null,
|
|
'must return null when runner is unavailable so caller can warn-and-skip');
|
|
});
|
|
});
|
|
|
|
// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ─────────────────────
|
|
// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text.
|
|
// These assertions cover the three bug-critical render semantics that
|
|
// text-matching tests would miss (silent EOL/quoting/passthrough regressions).
|
|
|
|
describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => {
|
|
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
|
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
|
|
|
test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field');
|
|
assert.strictEqual(
|
|
ir.eol.cmd,
|
|
'\r\n',
|
|
'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions',
|
|
);
|
|
});
|
|
|
|
test('invocation.target has no shell-metachar leakage (clean absolute path)', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
const target = ir.invocation.target;
|
|
assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string');
|
|
// The target stored in the IR is the raw unquoted path — quoting happens at
|
|
// render time. A metachar in the raw value means the IR is already corrupted.
|
|
assert.ok(
|
|
!target.includes('"') && !target.includes("'") && !target.includes('`'),
|
|
`invocation.target must be the raw path without shell quoting, got: ${target}`,
|
|
);
|
|
assert.ok(
|
|
target.endsWith('.js'),
|
|
`invocation.target must resolve to the .js script, got: ${target}`,
|
|
);
|
|
});
|
|
|
|
test('passthroughArgs is true — shim forwards all args via %*', () => {
|
|
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
|
assert.strictEqual(
|
|
ir.passthroughArgs,
|
|
true,
|
|
'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ────
|
|
|
|
describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => {
|
|
test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => {
|
|
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
|
const cmd = projectManagedHookCommand({
|
|
absoluteRunner: runner,
|
|
scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js',
|
|
runtime: 'codex',
|
|
platform: 'darwin',
|
|
});
|
|
assert.ok(typeof cmd === 'string', 'must return a string on darwin');
|
|
assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim');
|
|
assert.ok(
|
|
cmd.includes('gsd-check-update.js'),
|
|
`darwin command must reference the .js hook directly, got: ${cmd}`,
|
|
);
|
|
});
|
|
|
|
test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => {
|
|
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
|
const cmd = projectManagedHookCommand({
|
|
absoluteRunner: runner,
|
|
scriptPath: '/home/me/.codex/hooks/gsd-check-update.js',
|
|
runtime: 'codex',
|
|
platform: 'linux',
|
|
});
|
|
assert.ok(typeof cmd === 'string', 'must return a string on linux');
|
|
assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim');
|
|
assert.ok(
|
|
cmd.includes('gsd-check-update.js'),
|
|
`linux command must reference the .js hook directly, got: ${cmd}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ──
|
|
|
|
describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-3426-');
|
|
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
|
// Stub the hook file that must exist for the hook to be registered
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
|
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => {
|
|
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
|
|
|
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
|
absoluteRunner: fakeRunner,
|
|
platform: 'win32',
|
|
});
|
|
|
|
assert.ok(result.wrote || result.changed, 'must write hooks.json on win32');
|
|
|
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
|
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install');
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
|
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
|
.map((h) => h && h.command)
|
|
.filter((c) => typeof c === 'string');
|
|
|
|
assert.ok(commands.length > 0, 'must have at least one SessionStart hook command');
|
|
|
|
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
|
assert.ok(cmd, 'must have a gsd-check-update hook command');
|
|
|
|
// KEY ASSERTION: on win32, the command must reference a .cmd file — not bash
|
|
assert.ok(
|
|
cmd.includes('.cmd'),
|
|
`win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`,
|
|
);
|
|
assert.ok(
|
|
!cmd.toLowerCase().includes('bash'),
|
|
`win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`,
|
|
);
|
|
});
|
|
|
|
test('win32: .cmd shim file is written to the hooks directory', () => {
|
|
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
|
|
|
ensureCodexHooksJsonSessionStart(tmpDir, {
|
|
absoluteRunner: fakeRunner,
|
|
platform: 'win32',
|
|
});
|
|
|
|
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
|
assert.ok(
|
|
fs.existsSync(cmdShimPath),
|
|
`win32: .cmd shim must be written at ${cmdShimPath}`,
|
|
);
|
|
// File must be non-empty — structure check only (IR-first discipline)
|
|
const size = fs.statSync(cmdShimPath).size;
|
|
assert.ok(size > 0, '.cmd shim must have non-zero content');
|
|
});
|
|
|
|
test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => {
|
|
const fakeRunner = '"/usr/local/bin/node"';
|
|
|
|
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
|
absoluteRunner: fakeRunner,
|
|
platform: 'darwin',
|
|
});
|
|
|
|
assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin');
|
|
|
|
const hooksJson = JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
|
);
|
|
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
|
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
|
.map((h) => h && h.command)
|
|
.filter((c) => typeof c === 'string');
|
|
|
|
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
|
assert.ok(cmd, 'must have a gsd-check-update hook command on darwin');
|
|
|
|
// Counter-test: darwin must NOT use a .cmd shim
|
|
assert.ok(
|
|
!cmd.endsWith('.cmd'),
|
|
`darwin hook command must NOT reference a .cmd shim, got: ${cmd}`,
|
|
);
|
|
assert.ok(
|
|
cmd.includes('gsd-check-update.js'),
|
|
`darwin hook command must reference the .js file directly, got: ${cmd}`,
|
|
);
|
|
|
|
// .cmd shim must NOT be written on darwin
|
|
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
|
assert.ok(
|
|
!fs.existsSync(cmdShimPath),
|
|
'darwin must NOT write a .cmd shim',
|
|
);
|
|
});
|
|
|
|
test('non-Windows (linux): same as darwin — no .cmd shim', () => {
|
|
const fakeRunner = '"/usr/local/bin/node"';
|
|
|
|
ensureCodexHooksJsonSessionStart(tmpDir, {
|
|
absoluteRunner: fakeRunner,
|
|
platform: 'linux',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
|
);
|
|
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
|
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
|
.map((h) => h && h.command)
|
|
.filter((c) => typeof c === 'string');
|
|
|
|
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
|
assert.ok(cmd, 'linux must have a gsd-check-update hook command');
|
|
assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim');
|
|
|
|
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
|
assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim');
|
|
});
|
|
});
|
|
|
|
// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ─────────────────
|
|
|
|
describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => {
|
|
let tmpDir;
|
|
|
|
function withCodexHome(dir, fn) {
|
|
const prev = process.env.CODEX_HOME;
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
|
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
|
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too so this in-process install
|
|
// never touches the developer/CI machine's real home directory — confined
|
|
// entirely to `dir`, which the caller cleans up.
|
|
const prevHome = process.env.HOME;
|
|
const prevUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = dir;
|
|
process.env.HOME = dir;
|
|
process.env.USERPROFILE = dir;
|
|
try { return fn(); }
|
|
finally {
|
|
if (prev == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = prev;
|
|
if (prevHome == null) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = prevUserProfile;
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-3426-uninstall-');
|
|
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
|
// Write the .js hook (required by install) and a pre-existing .cmd shim
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
|
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'),
|
|
'@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n',
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('uninstall removes gsd-check-update.cmd from hooks directory', () => {
|
|
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
|
assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall');
|
|
|
|
withCodexHome(tmpDir, () => uninstall(true, 'codex'));
|
|
|
|
assert.ok(
|
|
!fs.existsSync(cmdShimPath),
|
|
`gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─
|
|
|
|
describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-3426-upgrade-');
|
|
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
|
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => {
|
|
const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
// Pre-existing stale hooks.json with node-runner command (v1.42.3 shape)
|
|
const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`;
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks.json'),
|
|
JSON.stringify({
|
|
SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }],
|
|
}, null, 2),
|
|
);
|
|
|
|
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
|
ensureCodexHooksJsonSessionStart(tmpDir, {
|
|
absoluteRunner: fakeRunner,
|
|
platform: 'win32',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
|
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
|
.map((h) => h && h.command)
|
|
.filter((c) => typeof c === 'string');
|
|
|
|
const gsdCmds = commands.filter((c) => c.includes('gsd-check-update'));
|
|
// Exactly one managed hook after migration — no duplicates
|
|
assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`);
|
|
|
|
// Must be the .cmd shim
|
|
assert.ok(
|
|
gsdCmds[0].includes('.cmd'),
|
|
`migrated command must reference .cmd shim, got: ${gsdCmds[0]}`,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3427-3433-codex-install-shape.test.cjs — consolidation epic #1969 (B1 #1970)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3427-3433-codex-install-shape (consolidation epic #1969 B1 #1970)", () => {
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
|
|
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
|
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
|
// syntax-checks them with vm — it does not compile/bundle anything. See
|
|
// tests/helpers/timeouts.cjs for the class-norm justification.
|
|
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
function withCodexHome(codexHome, fn) {
|
|
const prev = process.env.CODEX_HOME;
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
|
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
|
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
|
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
|
// in this file) — so this in-process install never touches the developer/CI
|
|
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
|
const prevHome = process.env.HOME;
|
|
const prevUserProfile = process.env.USERPROFILE;
|
|
const fakeHome = path.dirname(codexHome);
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = fakeHome;
|
|
process.env.USERPROFILE = fakeHome;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (prev == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = prev;
|
|
if (prevHome == null) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = prevUserProfile;
|
|
}
|
|
}
|
|
|
|
function extractSessionStartCommandsFromHooksJson(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
|
const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks))
|
|
? value.hooks
|
|
: value;
|
|
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
|
return sessionStart.flatMap((entry) => {
|
|
const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string');
|
|
});
|
|
}
|
|
|
|
describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
throwIfFailed(
|
|
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
|
`node ${BUILD_HOOKS_SCRIPT}`,
|
|
);
|
|
}
|
|
tmpRoot = createTempDir('gsd-3427-3433-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => {
|
|
// Stale legacy body — fresh install must overwrite this so Codex sees the
|
|
// current SKILL.md, not whatever was last on disk.
|
|
const legacySkillBody = '# old managed\n';
|
|
fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody);
|
|
const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex');
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({
|
|
version: 1,
|
|
files: {
|
|
'skills/gsd-help/SKILL.md': legacyHash,
|
|
},
|
|
}, null, 2));
|
|
|
|
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n');
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
// #2088: the managed gsd-* skill surface now regenerates at the
|
|
// canonical $HOME/.agents/skills root (fakeHome === tmpRoot here — see
|
|
// withCodexHome above), not under the legacy $CODEX_HOME/skills.
|
|
const newSkillsDir = codexSkillsRoot(tmpRoot);
|
|
const newEntries = fs.existsSync(newSkillsDir)
|
|
? fs.readdirSync(newSkillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
|
: [];
|
|
|
|
// #3562: $gsd-* commands are discoverable only when
|
|
// .agents/skills/gsd-*/SKILL.md exists. The installer must regenerate
|
|
// (not remove) the managed gsd-* directories.
|
|
assert.equal(newEntries.includes('gsd-help'), true);
|
|
const refreshedBody = fs.readFileSync(path.join(newSkillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
|
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
|
|
const frontmatter = parseFrontmatter(refreshedBody);
|
|
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
|
|
|
|
// #2088 migration: the installer cleans stale gsd-* dirs out of the old
|
|
// $CODEX_HOME/skills location on a pre-move install.
|
|
const legacyHelpDir = path.join(codexHome, 'skills', 'gsd-help');
|
|
assert.equal(fs.existsSync(legacyHelpDir), false, 'migration must remove the stale legacy gsd-help skill dir from $CODEX_HOME/skills');
|
|
|
|
// Unrelated user skills are preserved in place — migration only removes
|
|
// `gsd-*` dirs from the old location; non-gsd-* user dirs are untouched.
|
|
const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md');
|
|
assert.equal(fs.existsSync(userSkill), true, 'unrelated user skill must survive the #2088 migration');
|
|
});
|
|
|
|
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
|
|
const configToml = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'[[hooks.SessionStart]]',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml);
|
|
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
|
],
|
|
},
|
|
],
|
|
}, null, 2));
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
|
const tomlSessionStart = parsedToml.hooks?.SessionStart ?? [];
|
|
const tomlCommands = tomlSessionStart.flatMap((entry) =>
|
|
(Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string')
|
|
);
|
|
assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false);
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
|
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
|
|
|
assert.equal(gsdCommands.length, 1);
|
|
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
});
|
|
|
|
test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => {
|
|
const hooksDir = path.join(codexHome, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n');
|
|
const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: `node "${managedHookPath}"` },
|
|
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
|
],
|
|
},
|
|
],
|
|
}, null, 2));
|
|
|
|
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
|
// On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command.
|
|
// Either way the managed hook is gone after uninstall — only the user hook remains.
|
|
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
|
|
|
assert.equal(gsdCommands.length, 0);
|
|
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
{
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { cleanup, createTempDir } = require('./helpers.cjs');
|
|
const { install: installFor4544 } = require('../bin/install.js');
|
|
const installModule = require('../bin/install.js');
|
|
|
|
// Harness copy of runCodexInstall — the canonical one lives inside a folded
|
|
// block above and is not visible at this scope.
|
|
function runCodexInstall(codexHome) {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = codexHome;
|
|
try {
|
|
process.chdir(path.join(__dirname, '..'));
|
|
return installFor4544(true, 'codex');
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
describe('#4544 — manifest-driven rollback covers hooks/, scripts/, gsd-core payload, and the manifest', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-4544-rollback-');
|
|
codexHome = path.join(tmpDir, 'codex-home');
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete installModule.__codexSchemaValidator;
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
/** Seed a shape-valid prior-install manifest listing `relPaths`. */
|
|
function seedPriorManifest(relPaths) {
|
|
const files = {};
|
|
for (const rel of relPaths) files[rel] = 'prior-install-hash';
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'gsd-file-manifest.json'),
|
|
JSON.stringify({ manifestVersion: 2, version: '1.12.0', files }),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
function forceValidationFailure() {
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'simulated failure for #4544 rollback test',
|
|
});
|
|
}
|
|
|
|
function runFailingInstall() {
|
|
let err = null;
|
|
try { runCodexInstall(codexHome); } catch (e) { err = e; }
|
|
assert.ok(err, 'install must throw when validation fails');
|
|
assert.match(
|
|
String(err && err.message),
|
|
/post-write Codex schema validation failed/,
|
|
'the throw must be the injected validation failure, not an unrelated error'
|
|
);
|
|
}
|
|
|
|
test('restores prior-manifest files the failed install overwrote (#4544 must-have)', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
const sentinels = new Map([
|
|
['gsd-core/CHANGELOG.md', 'SENTINEL-CHANGELOG'],
|
|
['gsd-core/.gsd-runtime', 'SENTINEL-RUNTIME'],
|
|
['scripts/lib/drift-scan.cjs', 'SENTINEL-LIB'],
|
|
]);
|
|
fs.mkdirSync(path.join(codexHome, 'gsd-core'), { recursive: true });
|
|
fs.mkdirSync(path.join(codexHome, 'scripts', 'lib'), { recursive: true });
|
|
for (const [rel, body] of sentinels) {
|
|
fs.writeFileSync(path.join(codexHome, rel), body, 'utf8');
|
|
}
|
|
seedPriorManifest([...sentinels.keys()]);
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
for (const [rel, body] of sentinels) {
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, rel), 'utf8'),
|
|
body,
|
|
`rollback must restore the pre-install bytes of ${rel}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('re-deletes a prior-manifest path that was absent before the install', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
// Listed by the prior install but deleted before this one: the null
|
|
// snapshot branch must remove whatever the failed install recreates.
|
|
seedPriorManifest(['scripts/lib/drift-scan.cjs']);
|
|
assert.strictEqual(fs.existsSync(path.join(codexHome, 'scripts')), false,
|
|
'fixture precondition: the path must not exist pre-install');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'scripts', 'lib', 'drift-scan.cjs')),
|
|
false,
|
|
'rollback must re-delete a prior-manifest path the user had removed'
|
|
);
|
|
});
|
|
|
|
test('wholesale-restores the hooks/ directory (the Codex manifest omits it)', () => {
|
|
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'),
|
|
'SENTINEL-USER-EDITED-HOOK', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'utf8'),
|
|
'SENTINEL-USER-EDITED-HOOK',
|
|
'rollback must restore the pre-install hook bytes the install overwrote'
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'hooks', 'package.json')),
|
|
false,
|
|
'the CommonJS marker the failed install staged must not survive rollback'
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs')),
|
|
false,
|
|
'a staged hook file that did not pre-exist must not survive rollback'
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'hooks', 'lib')),
|
|
false,
|
|
'transitive hooks/lib/ helpers staged by the failed install must not survive rollback'
|
|
);
|
|
});
|
|
|
|
test('preserves pre-existing user files under hooks/', () => {
|
|
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'hooks', 'my-own.sh'), '#!/bin/sh\necho mine\n', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'hooks', 'my-own.sh'), 'utf8'),
|
|
'#!/bin/sh\necho mine\n',
|
|
'a user-owned hooks/ file that predated the install must survive rollback'
|
|
);
|
|
});
|
|
|
|
test('restores the prior gsd-file-manifest.json the failed install rewrote', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
const priorManifest = JSON.stringify({
|
|
manifestVersion: 2, version: '1.12.0',
|
|
files: { 'gsd-core/CHANGELOG.md': 'prior-hash' },
|
|
}, null, 2);
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), priorManifest, 'utf8');
|
|
fs.mkdirSync(path.join(codexHome, 'gsd-core'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-core', 'CHANGELOG.md'), 'SENTINEL', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'utf8'),
|
|
priorManifest,
|
|
'rollback must restore the prior manifest bytes'
|
|
);
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'gsd-core', 'CHANGELOG.md'), 'utf8'),
|
|
'SENTINEL',
|
|
'the manifest-listed file must be restored alongside the manifest itself'
|
|
);
|
|
});
|
|
|
|
test('clean-first-install rollback leaves no manifest or hooks residue', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'gsd-file-manifest.json')),
|
|
false,
|
|
'no prior manifest existed, so the manifest the failed install wrote must be gone'
|
|
);
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(codexHome, 'hooks')),
|
|
false,
|
|
'hooks/ must not exist at all after a clean-first-install rollback (nothing pre-existed)'
|
|
);
|
|
});
|
|
|
|
test('a malformed prior manifest degrades gracefully', () => {
|
|
// No VERSION pre-exists, so the five-target restore must still remove the
|
|
// one the failed install wrote -- an unreadable prior manifest must cost
|
|
// the original restores nothing.
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'garbage{{{', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false,
|
|
'VERSION rollback must still work when the prior manifest is unreadable');
|
|
});
|
|
|
|
test('a prior manifest that is a JSON array degrades gracefully', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), '[]', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
// The array file PRE-EXISTED, so it is pre-install state: rollback
|
|
// restores those exact bytes rather than deleting them. What must be gone
|
|
// is any manifest the failed install wrote over it.
|
|
assert.strictEqual(fs.readFileSync(path.join(codexHome, 'gsd-file-manifest.json'), 'utf8'), '[]',
|
|
'an array-shaped prior manifest is restored as pre-install state; rollback must not crash on it');
|
|
});
|
|
|
|
test('a traversal-shaped manifest key is skipped, not written', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
const canary = path.join(tmpDir, 'evil.txt');
|
|
fs.writeFileSync(canary, 'do-not-touch', 'utf8');
|
|
// `../evil.txt` resolves OUTSIDE codexHome — resolveInstallRelativePath
|
|
// must reject it, and the snapshotter must skip the key entirely.
|
|
seedPriorManifest(['../evil.txt']);
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(fs.readFileSync(canary, 'utf8'), 'do-not-touch',
|
|
'a traversal manifest key must never cause a write outside the install root');
|
|
});
|
|
|
|
test('very early failure: the new restores stay idempotent before any capture', () => {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(fs.existsSync(path.join(codexHome, 'gsd-file-manifest.json')), false,
|
|
'an empty home must still be empty after rollback');
|
|
assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks')), false,
|
|
'an empty home must have no hooks/ residue after rollback');
|
|
});
|
|
|
|
test('minimal-mode rollback never touches a pre-existing hooks/ tree (capture gate)', () => {
|
|
// BLOCKER regression (#4544 review): the capture gate is off in minimal
|
|
// mode, and the restore must treat "no snapshot" as "do nothing" — never
|
|
// as "hooks/ was absent". Seeds the profile marker so the in-process
|
|
// install runs minimal without a validator-unreachable subprocess.
|
|
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'SENTINEL-OLD-HOOK', 'utf8');
|
|
fs.writeFileSync(path.join(codexHome, 'hooks', 'user-backup.js'), 'PRECIOUS-USER-DATA', 'utf8');
|
|
fs.writeFileSync(path.join(codexHome, '.gsd-profile'), 'core', 'utf8');
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'hooks', 'gsd-check-update.js'), 'utf8'),
|
|
'SENTINEL-OLD-HOOK',
|
|
'minimal-mode rollback must preserve the pre-existing hook file'
|
|
);
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(codexHome, 'hooks', 'user-backup.js'), 'utf8'),
|
|
'PRECIOUS-USER-DATA',
|
|
'minimal-mode rollback must preserve user files under hooks/ (empty snapshot means do nothing)'
|
|
);
|
|
});
|
|
|
|
test('a symlink under hooks/ is never followed; downgrade preserves it uncaptured', (t) => {
|
|
const canaryDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-4544-canary-'));
|
|
t.after(() => cleanup(canaryDir));
|
|
const canary = path.join(canaryDir, 'secret.txt');
|
|
fs.writeFileSync(canary, 'DO-NOT-READ', 'utf8');
|
|
|
|
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
|
|
const linkPath = path.join(codexHome, 'hooks', 'evil-link');
|
|
try {
|
|
fs.symlinkSync(canary, linkPath);
|
|
} catch (_) {
|
|
t.skip('symlinks unavailable on this platform/filesystem');
|
|
return;
|
|
}
|
|
|
|
forceValidationFailure();
|
|
runFailingInstall();
|
|
|
|
assert.strictEqual(fs.readFileSync(canary, 'utf8'), 'DO-NOT-READ',
|
|
'the symlink referent must never be read into the snapshot or written over');
|
|
// The link makes the capture incomplete, so the restore downgrades to
|
|
// per-file: nothing uncaptured is deleted — the pre-existing link is
|
|
// pre-install state and survives, still pointing at its referent.
|
|
assert.strictEqual(fs.readFileSync(linkPath, 'utf8'), 'DO-NOT-READ',
|
|
'the preserved link must still resolve to the untouched referent');
|
|
assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'secret.txt')), false,
|
|
'the referent bytes must not leak into the install tree as a regular file');
|
|
});
|
|
});
|
|
}
|