Files
msd-core/tests/codex-config-hooks.test.cjs
Dennis Alexis Valin Dittrich ad1477d659 enhance(#4154): validate configured entrypoints before reporting install success (#4249)
* 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>
2026-09-16 04:23:53 -04:00

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');
});
});
}