Files
msd-core/tests/codex-config-install.test.cjs
Tom Boucher a27cb6b2fa enhance(#4139): Phase 6 — the lazily-read remainder and the artifact templates (#4540)
* enhance(#4406): the lazily-read remainder and the artifact templates

ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b
(gsd-core/workflows/<name>/{modes,steps,templates}/*.md) and stream 4
(gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user:
two independent, complete files per covered path (canonical + .compact.md
sibling), with the gate picking which one gets Read at the call site. This is
a different shape from Phase 5's spine+detail partition, and is safe here
specifically because these files are already reached only by a runtime Read —
a missed Read already means zero overlay content today, with or without
workflow.compact_content, so selecting between two independently-complete
files introduces no new failure mode (documented in
gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section).

Disposition, after inspecting every candidate rather than trusting a byte-size
threshold (same rigor Phase 5 applied to review.md):

- Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing
  reference doc emitted verbatim, not orchestrator instruction). The other 9
  size-threshold candidates are dominated by fail-closed guards, exact CLI
  invocations, or output-format contracts (AskUserQuestion blocks) — recorded
  not-worth-compacting, same reasoning as Phase 5's review.md.
- Stream 4: a ground-truth reachability audit replaced the initial size-only
  candidate list. Two files (summary.md, user-setup.md) got compact variants;
  a third (spec.md) was drafted, then dropped after discovering its only two
  call sites are eager @-includes, not a runtime Read — stream-1 material
  hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md
  itself has 3 eager call sites and only 1 genuine runtime-Read call site
  (execute-plan.md); only that one was wired, so the compact variant's savings
  apply to the sequential single-plan execution path only.
- Discovered while auditing reachability: 12 gsd-core/templates/** files with
  zero references anywhere in workflow/agent/command prose, compiled source,
  or tests — dead scaffolding predating this phase. Deleted in this same PR
  per this repo's no-defer policy, after re-verifying against a computed
  path.join(...) pattern (not just a plain-string search) that nearly caused
  two genuinely load-bearing templates (user-profile.md, dev-preferences.md)
  to be misclassified as dead.

New checker (tests/helpers/compact-content-variant.cjs): registration,
reachability, protected-content-preserved, size-smaller — replacing Phase
3/5's disjointness/completeness checks, which assume a partition rather than
two deliberately-overlapping documents. The reachability check's own
"unprefixed match" guard had a real bug (rejected the repo's own
`~/.claude/gsd-core/...` convention), caught by running it against the
already-wired help/modes/full.compact.md pair rather than only synthetic
fixtures — fixed to anchor on the nearest `gsd-core` path segment instead.

Template consumer parity (tests/compact-content-template-variant-parity.test.cjs):
proves each compact variant's `## File Template` fenced block — the actual
output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed
against — is byte-identical to the canonical file, then runs the one real
deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent,
backing `gsd-tools uat classify-coverage`) against content built from that
shared contract.

Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs)
rather than extending the existing spine/detail one — different data shape,
and the existing script's own contract deliberately isolates it from a
test-only helper's shape changing.

Emitted-drift acknowledgement: not needed. Every changed/added path in this
diff is hand-authored and present in the diff itself, so diffEmitted's
attribution loop resolves `via` to the path's own source before reaching the
ack-lookup branch (same reasoning Phase 5 verified for its own diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* enhance(#4406): address code-review findings on the variant-swap gate

- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content entries described only the spine+detail mechanism
  (Phase 5) and were missing this phase's variant-swap mechanism and its
  benchmark:compact-content-variants script entirely — required since this
  PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets"
  rule). Both now describe both mechanisms and which call sites are wired.
- Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved:
  a canonical file with zero <!-- gsd:protected --> blocks must be a
  no-op, not a violation — the only branch of that function the existing
  fixtures didn't exercise.
- Collapsed findCompactFiles/findMarkdownFiles in
  tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix
  helper — the two were identical recursive walks differing only in the
  extension predicate (minor Duplicated-Code finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification

gsd-test caught this, not static analysis: 10 real failures in
tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs,
and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install
path, which does
fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'))
after copying gsd-core/templates/** into the target project, then merges it into both
.github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit
that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the
repo-root bin/install.js — a separately maintained installer bundle outside the
src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around
that read degrades to a silent skip rather than a crash when the template is missing,
which is why this surfaced only once the real E2E install test ran, not from any
static check.

Re-verified the remaining 11 deleted filenames against bin/install.js specifically
(plain substring and quoted-filename search) before trusting that list — all 11 have
zero hits there, confirmed dead by the same standard this one file failed.

Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to
reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md)
and the phase design doc from 12 to 11 deleted files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants
Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule

* docs(#4406): backfill changeset PR numbers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion

CI's own full-test matrix (not gsd-test's matrix, which does not run this
check) caught 4 more false-positive dead-template classifications via
tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs
— a literal, word-boundary basename check across .github/workflows/,
gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every
file a PR deletes. It has no semantic awareness, so a deleted template's
basename colliding with something else entirely still fires:

- claude-md.md: gsd-core/templates/README.md had a stale table row claiming
  /gsd-profile reads this template to generate CLAUDE.md. Verified false (no
  code reads it anywhere, same search that already covered bin/install.js) —
  fixed the row to *(inline)*, matching every other command-generated
  artifact in that table. File stays deleted.
- codebase/testing.md: collided with docs/guides/testing.md, an illustrative
  example row in docs-update.md's sample output table (an unrelated real
  generated-docs path). Swapped the example topic to "contributing" — the
  row is illustrative, any topic works. File stays deleted.
- codebase/architecture.md, codebase/stack.md: collided with docs/reference/
  planning-artifacts.md's directory listing of a user's own generated
  .planning/codebase/architecture.md and stack.md output — the same
  semantic mismatch already investigated and dismissed as unrelated earlier
  in this phase's audit, now caught by a gate instead of judgment. That
  listing repeats across 5 locale copies of the doc.
- continue-here.md: collided with the real .continue-here.md pause-work
  artifact, referenced across 15+ locale and workflow files.

For the last two, the lint's own error message offers "restore the file or
update every consumer in the same commit." Rewording 15+ files across
languages I cannot verify translation quality for, to shave 2 already-tiny
templates that were merely presumed dead, is disproportionate to this PR's
actual scope — restored codebase/architecture.md, codebase/stack.md, and
continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates
section accordingly.

Final confirmed-dead set: claude-md.md, codebase/concerns.md,
codebase/conventions.md, codebase/integrations.md, codebase/structure.md,
codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down
from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next
node scripts/lint-removed-but-needed.cjs now passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes

* fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout

Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the
user asked to be actually fixed, not just re-run past: PR #4497 (landed
2026-09-07, one day before this PR's CI run) isolated
tests/codex-config.test.cjs into its own dedicated chunk because its
measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe
to share a chunk with any other file. That isolation was necessary but not
sufficient — even alone, with zero companion-file contention, the file's
real Windows execution time sits right at the 600s per-chunk ceiling. Two
independent CI runs on two unrelated PRs (this one and #4154) were both
killed within ~1.4s of the identical 600000ms mark — not random contention,
a deterministic near-miss the isolation fix couldn't address because it
never reduced the file's own cost, only removed the risk of a companion
file's cost stacking on top of it (which the PR #4497 comment explicitly
anticipated: "if a future profiling pass genuinely speeds up
codex-config.test.cjs itself, this isolation can be revisited").

The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79
describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760,
#3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more),
several of which are explicitly documented as "folded" in from separate
files that were never actually split back out ("Verified non-duplicate
against both the pre-existing target and the other three folded sources").

Split into 4 files by top-level AST statement boundaries (never a naive
column-0 regex — an early attempt at that overcounted 79 apparent
"describe(" matches when only 21 are genuinely top-level; the rest are
nested inside a handful of large folded-in blocks, which a regex can't tell
apart from real top-level statements). Verified lossless twice: the split
script asserts byte-for-byte reconstruction of every source character, and
independently, total test()/describe() call counts match exactly between
the original file and the sum across all 4 new files (433/79 both sides).
Each new file carries the complete original shared header (imports/helpers)
for safety; per-file unused-import warnings from that duplication are
resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard
form for an intentionally-unused destructured binding — never a bare `{
_foo }`, which would destructure a different, nonexistent property).

No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its
pinned test in tests/run-tests-harness.test.cjs: the file that keeps the
original name (tests/codex-config.test.cjs) is now only ~28% of the
original's size and safely isolated in its own chunk as before; the other
three new files re-enter normal weight-balanced packing, none individually
close to disproportionate. Confirmed no other file hardcodes the hardcoded
filename anywhere that would silently stop these tests from running (the
CI test-selection scripts determine scope algorithmically, not by literal
filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:31:04 -04:00

2895 lines
124 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, 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 fc = require('fast-check');
const { CLAUDE_AGENT_ALIASES: _CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs');
const { escapeRegex: _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,
CODEX_EXTENDED_HOOK_EVENTS,
} = require('../bin/install.js');
const { 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-3562-codex-install-skill-surface.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3562-codex-install-skill-surface (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Regression test for bug #3562 — Codex global install must create a
* discoverable $gsd-* skill surface.
*
* Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover
* commands from gsd-core/workflows/*.md or agents/*.md. It only registers
* commands from skills/<name>/SKILL.md. Prior installer logic ("Codex now
* discovers official skills from .agents/skills") was based on an assumption
* that does not match the shipping Codex CLI behavior, leaving users with
* workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core
* --codex --global`.
*
* Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path
* so the same skill-shape that Claude / Copilot / Antigravity / Cursor /
* Windsurf / Augment / Trae installs produce is also produced for Codex.
*/
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 { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { install } = 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;
}
}
describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { 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-3562-');
codexHome = path.join(tmpRoot, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
test('global install creates skills/gsd-help/SKILL.md', () => {
withCodexHome(codexHome, () => install(true, 'codex'));
// #2088: skills now install to the canonical $HOME/.agents/skills root.
// withCodexHome fakes $HOME to tmpRoot (codexHome's parent) above.
const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md');
assert.ok(
fs.existsSync(skillPath),
`Codex install must create ${skillPath} so $gsd-help is discoverable. ` +
'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.',
);
});
test('SKILL.md content has frontmatter expected by Codex skill discovery', () => {
withCodexHome(codexHome, () => install(true, 'codex'));
const skillPath = path.join(codexSkillsRoot(tmpRoot), 'gsd-help', 'SKILL.md');
assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists');
const content = fs.readFileSync(skillPath, 'utf8');
const frontmatter = parseFrontmatter(content);
assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves');
});
test('multiple core $gsd-* skills are produced (not just gsd-help)', () => {
withCodexHome(codexHome, () => install(true, 'codex'));
const skillsDir = codexSkillsRoot(tmpRoot);
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install');
const gsdSkills = fs
.readdirSync(skillsDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
.map((e) => e.name);
// Lower bound — exact count depends on the current command surface. The
// commands/gsd/ directory holds dozens of *.md files; expecting more than
// 10 generated skills is a conservative floor that catches "we generated
// nothing" or "we only generated one accidentally" regressions.
assert.ok(
gsdSkills.length >= 10,
`Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`,
);
});
test('install preserves existing user skills (does not remove unrelated dirs)', () => {
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
fs.writeFileSync(
path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'),
'---\nname: custom-user-skill\n---\n# user skill\n',
);
withCodexHome(codexHome, () => install(true, 'codex'));
const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md');
assert.ok(
fs.existsSync(userSkill),
'Codex install must preserve existing non-gsd user skill directories',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3566-codex-hooks-feature-canonical-key (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Regression tests for bug #3566 — Codex installer must emit canonical
* [features].hooks (not the legacy [features].codex_hooks).
*
* Codex itself marks `codex_hooks` as a `legacy_key` in
* codex-rs/features/src/legacy.rs. The canonical current feature flag is
* `hooks`. The GSD installer was still writing `codex_hooks` on every fresh
* install / reinstall, leaving deprecated config behind. This file pins:
*
* 1. Fresh install writes canonical `[features].hooks = true` and never
* emits `codex_hooks` (section, root-dotted, or block-fallback forms).
* 2. Reinstall over a GSD-owned section-form legacy
* `[features].codex_hooks = true` migrates forward to
* `[features].hooks = true` (legacy line removed); user-owned legacy
* entries are preserved per #2760.
* 3. Reinstall over a GSD-owned root-dotted legacy
* `features.codex_hooks = true` migrates forward to
* `features.hooks = true`; user-owned legacy entries are preserved.
* 4. Reinstall over a user-owned `[features].hooks = true` (no GSD
* ownership marker) preserves the user line; no double-write, no
* ownership stamp.
* 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical
* `hooks` AND legacy `codex_hooks` as "enabled" so existing installs
* keep working across the migration window.
* 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy
* `codex_hooks`; user-owned `hooks` is preserved.
*
* All assertions use parseTomlToObject — never substring-match on raw TOML
* text (per RULESET.TESTS.no-source-grep). The product surface is the
* parsed config shape, not the file's lexical layout.
*/
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 { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
const { createTempDir, cleanup } = 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 readConfig(codexHome) {
const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
return { text, parsed: parseTomlToObject(text) };
}
function featuresHooks(parsed) {
return parsed?.features?.hooks;
}
function featuresCodexHooks(parsed) {
return parsed?.features?.codex_hooks;
}
describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { 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-3566-');
codexHome = path.join(tmpRoot, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
test('fresh install writes [features].hooks = true and never emits codex_hooks', () => {
withCodexHome(codexHome, () => install(true, 'codex'));
const { parsed } = readConfig(codexHome);
assert.strictEqual(
featuresHooks(parsed),
true,
'fresh install must write canonical [features].hooks = true',
);
assert.strictEqual(
featuresCodexHooks(parsed),
undefined,
'fresh install must NOT write legacy [features].codex_hooks',
);
});
test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => {
// A user who hand-wrote `codex_hooks = true` keeps the legacy key.
// Codex itself maps it via the runtime legacy_key alias, so this is
// forward-compatible without GSD rewriting user-authored content.
const legacy = [
'[features]',
'codex_hooks = true',
'',
].join('\n');
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
withCodexHome(codexHome, () => install(true, 'codex'));
const { parsed } = readConfig(codexHome);
assert.strictEqual(
featuresCodexHooks(parsed),
true,
'user-owned legacy codex_hooks line must be preserved verbatim',
);
});
test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => {
const legacy = 'features.codex_hooks = true\n';
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
withCodexHome(codexHome, () => install(true, 'codex'));
const { parsed } = readConfig(codexHome);
assert.strictEqual(
featuresCodexHooks(parsed),
true,
'user-owned root-dotted legacy line must be preserved verbatim',
);
});
test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => {
const userOwned = [
'[features]',
'hooks = true',
'',
].join('\n');
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
withCodexHome(codexHome, () => install(true, 'codex'));
const { parsed } = readConfig(codexHome);
assert.strictEqual(
featuresHooks(parsed),
true,
'user-owned hooks=true must be preserved',
);
});
test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => {
// Phase 1: fresh GSD install — writes GSD-owned hooks line.
withCodexHome(codexHome, () => install(true, 'codex'));
const { parsed: afterInstall } = readConfig(codexHome);
assert.strictEqual(
featuresHooks(afterInstall),
true,
'precondition: install wrote canonical hooks',
);
withCodexHome(codexHome, () => uninstall(true, 'codex'));
const configPath = path.join(codexHome, 'config.toml');
if (!fs.existsSync(configPath)) {
// Uninstall may delete config.toml entirely when nothing user-owned
// remains — that is the strongest possible "feature flag removed"
// signal and counts as success.
return;
}
const { parsed: afterUninstall } = readConfig(codexHome);
assert.notStrictEqual(
featuresHooks(afterUninstall),
true,
'uninstall must remove GSD-owned canonical hooks line',
);
});
test('uninstall preserves user-owned hooks=true when GSD never owned it', () => {
const userOwned = [
'[features]',
'hooks = true',
'',
].join('\n');
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
withCodexHome(codexHome, () => uninstall(true, 'codex'));
const { parsed } = readConfig(codexHome);
assert.strictEqual(
featuresHooks(parsed),
true,
'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3582-codex-skills-materialized.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3582-codex-skills-materialized (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for bug #3582 — Codex install must materialize the skill
* surface under `~/.codex/skills/<name>/SKILL.md`.
*
* Background: GSD 1.42.2 reported the user-visible failure
* > Skipped Codex skill-copy generation (Codex discovers official skills directly)
* which left users with a "successful" install but no routable `$gsd-*`
* entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover
* commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`;
* it only registers slash commands derived from `~/.codex/skills/<name>/SKILL.md`.
* The "Codex discovers official skills directly" assumption was wrong.
*
* The current installer (#3562 / current main) calls
* `copyCommandsAsCodexSkills()` to materialize one SKILL.md per
* commands/gsd/*.md, with Claude-flavored command frontmatter rewritten
* into Codex skill frontmatter and the `<codex_skill_adapter>` body
* produced by `getCodexSkillAdapterHeader()`.
*
* This test locks the install contract so the 1.42.2 regression cannot
* silently come back. It asserts the full expected skill-name set
* (deepStrictEqual, not just count), the full adapter block (using
* the exported `getCodexSkillAdapterHeader` IR as the expected value,
* not raw substring search), and the success/skip log invariant.
*/
// allow-test-rule: source-text-is-the-product (see #3582)
// This assertion validates the generated adapter block that is shipped to
// users in SKILL.md; matching exact emitted text is the contract under test.
'use strict';
// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.).
// Must be set BEFORE requiring bin/install.js; scoped to module load only
// so downstream tests don't see it. Mirrors the bug-2760 codex harness.
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 { install, getCodexSkillAdapterHeader } = require('../bin/install.js');
const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
if (previousGsdTestMode === undefined) {
delete process.env.GSD_TEST_MODE;
} else {
process.env.GSD_TEST_MODE = previousGsdTestMode;
}
const ROOT = path.join(__dirname, '..');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
// Strip ANSI color codes so log assertions don't depend on TTY detection.
function stripAnsi(s) {
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
return s.replace(/\x1b\[[0-9;]*m/g, '');
}
function assertNoBareGsdToolsInvocation(content, label) {
const patterns = [
/(^|\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`,
);
}
}
/**
* Walk commands/gsd/**\/*.md and return the set of skill names the installer
* is contractually obligated to produce. Naming rule mirrors
* `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to
* `gsd-<dir>-<file>` with the .md stripped.
*/
function expectedSkillNames() {
const names = new Set();
function recurse(dir, prefix) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`);
} else if (entry.name.endsWith('.md')) {
const base = entry.name.slice(0, -3);
names.add(`${prefix}-${base}`);
}
}
}
recurse(COMMANDS_DIR, 'gsd');
return names;
}
/**
* Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr.
* Cleans up codexHome on throw so a partial-install failure never leaks
* temp directories.
*/
function runCodexInstallCaptured() {
const codexHome = createTempDir('gsd-3582-codex-');
const logs = [];
const warnings = [];
const origLog = console.log;
const origWarn = console.warn;
console.log = (...a) => { logs.push(a.join(' ')); };
console.warn = (...a) => { warnings.push(a.join(' ')); };
const previousCodexHome = process.env.CODEX_HOME;
const previousCwd = process.cwd();
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
// home rooted at os.homedir() ($HOME/.agents), independent of CODEX_HOME.
// Sandbox $HOME (and $USERPROFILE) to codexHome too — otherwise this
// in-process install would materialize skills under the developer/CI
// machine's REAL home directory instead of the temp dir.
const previousHome = process.env.HOME;
const previousUserProfile = process.env.USERPROFILE;
process.env.CODEX_HOME = codexHome;
process.env.HOME = codexHome;
process.env.USERPROFILE = codexHome;
process.env.GSD_TEST_MODE = '1';
try {
process.chdir(ROOT);
install(true, 'codex');
return { codexHome, logs, warnings };
} catch (err) {
// Always reclaim the temp dir if install throws — otherwise the
// describe-level afterEach can't see codexHome and it leaks.
try { cleanup(codexHome); } catch { /* best-effort */ }
throw err;
} finally {
process.chdir(previousCwd);
console.log = origLog;
console.warn = origWarn;
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;
}
if (previousGsdTestMode === undefined) {
delete process.env.GSD_TEST_MODE;
} else {
process.env.GSD_TEST_MODE = previousGsdTestMode;
}
}
}
// concurrency:false — harness mutates console.* / process.env / process.cwd().
// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs.
describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => {
let installRun;
beforeEach(() => {
installRun = runCodexInstallCaptured();
});
afterEach(() => {
if (installRun && installRun.codexHome) {
cleanup(installRun.codexHome);
}
});
test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => {
const skillsDir = codexSkillsRoot(installRun.codexHome);
assert.ok(
fs.existsSync(skillsDir),
`Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`,
);
const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
// deepStrictEqual on the sorted full set — not just count — so a
// partial install that drops a real command and substitutes a bogus
// same-count `gsd-*` directory cannot pass.
const expected = [...expectedSkillNames()].sort();
assert.deepStrictEqual(
[...actualNames].sort(),
expected,
`installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`,
);
// Every skill dir contains a non-empty SKILL.md file. Empty dirs or
// empty SKILL.md bodies would defeat Codex's slash-command
// registration as silently as the 1.42.2 "skipped" branch did.
for (const name of actualNames) {
const skillMd = path.join(skillsDir, name, 'SKILL.md');
const stat = fs.statSync(skillMd);
assert.ok(stat.isFile(), `${skillMd} must be a regular file`);
assert.ok(stat.size > 0, `${skillMd} must not be empty`);
}
});
test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => {
const skillsDir = codexSkillsRoot(installRun.codexHome);
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
for (const name of skillDirs) {
const content = fs.readFileSync(
path.join(skillsDir, name, 'SKILL.md'),
'utf-8',
);
// Uses the shared `parseFrontmatter` from tests/helpers.cjs per the
// CONTRIBUTING.md "tests parse, never grep" convention.
const fm = parseFrontmatter(content);
assert.strictEqual(
fm.name,
name,
`SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`,
);
assert.ok(
typeof fm.description === 'string' && fm.description.length > 0,
`SKILL.md description must be a non-empty string for ${name}`,
);
}
});
test('SKILL.md body contains the full <codex_skill_adapter> block produced by the exported builder', () => {
// Structural check against the production builder's output — NOT a
// raw substring grep on the rendered file. `getCodexSkillAdapterHeader`
// is the typed IR exported by bin/install.js (#3582 PR #3609 codex
// review); the file on disk must contain its full output verbatim
// (open tag, body, closing `</codex_skill_adapter>`). A truncated,
// empty, or missing-closing-tag adapter cannot satisfy this assertion.
const skillsDir = codexSkillsRoot(installRun.codexHome);
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
for (const name of skillDirs) {
const expectedAdapter = getCodexSkillAdapterHeader(name);
// Sanity: the builder itself must produce a closed block for the
// assertion below to be meaningful.
assert.ok(
expectedAdapter.startsWith('<codex_skill_adapter>'),
`getCodexSkillAdapterHeader(${name}) must start with the opening tag`,
);
assert.ok(
expectedAdapter.trimEnd().endsWith('</codex_skill_adapter>'),
`getCodexSkillAdapterHeader(${name}) must end with the closing tag`,
);
const content = fs.readFileSync(
path.join(skillsDir, name, 'SKILL.md'),
'utf-8',
);
assert.ok(
content.includes(expectedAdapter),
`${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`,
);
}
});
test('representative skills named in the issue report are present', () => {
// The bug report and triage explicitly named these. Locking them as a
// representative set so a future dispatch / filter / profile change
// cannot drop just the commands the original user was trying to run.
const representative = [
'gsd-map-codebase', // the literal command from the bug report
'gsd-execute-phase',
'gsd-plan-phase',
'gsd-new-project',
'gsd-health',
];
const skillsDir = codexSkillsRoot(installRun.codexHome);
for (const name of representative) {
const skillMd = path.join(skillsDir, name, 'SKILL.md');
assert.ok(
fs.existsSync(skillMd),
`${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`,
);
}
});
test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => {
const skillsDir = codexSkillsRoot(installRun.codexHome);
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
for (const name of skillDirs) {
const content = fs.readFileSync(
path.join(skillsDir, name, 'SKILL.md'),
'utf-8',
);
assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`);
}
});
test('installer success log mentions skills/ — never claims success while skipping', () => {
// The 1.42.2 user-visible failure mode was a successful install that
// printed "Skipped Codex skill-copy generation (Codex discovers
// official skills directly)" while leaving the user with no
// entrypoints. Lock that the broken strings can NEVER coexist with a
// success indicator. Current main prints "✓ Installed N skills".
const cleanLogs = installRun.logs.map(stripAnsi);
const cleanWarnings = installRun.warnings.map(stripAnsi);
const allOutput = [...cleanLogs, ...cleanWarnings].join('\n');
assert.ok(
!/Skipped Codex skill-copy generation/i.test(allOutput),
`installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`,
);
assert.ok(
!/Codex discovers official skills directly/i.test(allOutput),
`installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`,
);
// Positive proof — at least one log line acknowledges the skills install.
const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line));
assert.ok(
hasSkillsInstalledLog,
`installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3808-codex-adapter-text-mode-fallback (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for bug #3808.
*
* When Codex runs in Default mode, `request_user_input` is reported as
* unavailable. The Codex skill adapter must tell the agent to activate the
* workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either:
* (a) silently picking a default value — the #3018 failure mode, or
* (b) ad-hoc plain-text fallback that bypasses the workflow's own branching.
*
* Workflows (e.g. plan-phase.md) already have TEXT_MODE logic:
* "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode
* from init JSON is true."
* The adapter must tell the agent to USE that mechanism when
* `request_user_input` is unavailable instead of inventing its own fallback
* or silently continuing with defaults.
*
* Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that
* prose rewording doesn't break tests as long as the semantics stay correct.
*/
'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.
*
* Flags for bug #3808 (TEXT_MODE activation):
* activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text?
* instructsStop — does the prose tell the agent to stop/halt/wait?
* presentsPlainText — does the prose mention plain-text / numbered-list presentation?
* silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking?
*/
function parseExecuteModeFallbackFor3808(section) {
if (!section || typeof section !== 'string') {
return {
ok: false,
sectionLength: 0,
activatesTextMode: false,
instructsStop: false,
presentsPlainText: false,
silentlyPicksDefaults: false,
};
}
const lower = section.toLowerCase();
// (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's
// built-in text mode mechanism when request_user_input is unavailable.
// Accept either: explicit "--text" flag mention OR "text_mode" / "text mode"
// paired with context showing it is being SET/ACTIVATED (not just referenced).
const mentionsTextFlag = section.includes('--text');
const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section);
const activatesTextMode = mentionsTextFlag || mentionsTextModeOn;
// (b) STOP/WAIT directive — the agent must halt instead of proceeding silently.
const instructsStop = /\b(stop|halt|wait)\b/.test(lower);
// (c) Plain-text fallback presentation.
const presentsPlainText = /plain.?text|numbered list/.test(lower);
// Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808.
const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section);
return {
ok: true,
sectionLength: section.length,
activatesTextMode,
instructsStop,
presentsPlainText,
silentlyPicksDefaults,
};
}
describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => {
const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work'];
test('getCodexSkillAdapterHeader is exported', () => {
assert.equal(typeof getCodexSkillAdapterHeader, 'function');
});
test('Execute mode fallback section exists for all key skills', () => {
for (const skillName of SKILL_NAMES) {
const header = getCodexSkillAdapterHeader(skillName);
const section = extractExecuteModeFallback(header);
assert.ok(section !== null && section.length > 0,
`${skillName}: Execute mode fallback section must exist and have content`);
}
});
for (const skillName of SKILL_NAMES) {
test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => {
const header = getCodexSkillAdapterHeader(skillName);
const section = extractExecuteModeFallback(header);
const parsed = parseExecuteModeFallbackFor3808(section);
assert.equal(parsed.activatesTextMode, true,
`${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`);
});
test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => {
const header = getCodexSkillAdapterHeader(skillName);
const section = extractExecuteModeFallback(header);
const parsed = parseExecuteModeFallbackFor3808(section);
assert.equal(parsed.instructsStop, true,
`${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`);
});
test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => {
const header = getCodexSkillAdapterHeader(skillName);
const section = extractExecuteModeFallback(header);
const parsed = parseExecuteModeFallbackFor3808(section);
assert.equal(parsed.silentlyPicksDefaults, false,
`${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`);
});
}
test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => {
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase'));
const parsed = parseExecuteModeFallbackFor3808(section);
assert.deepStrictEqual(
{
ok: parsed.ok,
activatesTextMode: parsed.activatesTextMode,
instructsStop: parsed.instructsStop,
presentsPlainText: parsed.presentsPlainText,
silentlyPicksDefaults: parsed.silentlyPicksDefaults,
},
{
ok: true,
activatesTextMode: true,
instructsStop: true,
presentsPlainText: true,
silentlyPicksDefaults: false,
},
`gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-570-codex-leak-scanner.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-570-codex-leak-scanner (consolidation epic #1969 B1 #1970)", () => {
// allow-test-rule: source-text-is-the-product (see #570)
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Regression tests for issue #570 — three related sub-bugs in the Codex leak
* scanner and supporting infrastructure.
*
* SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir,
* including pre-existing unrelated files that contain ~/.claude references.
* Fix: scan only files listed in gsd-file-manifest.json.
*
* SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing
* slash) but NOT bare "~/.claude" (no slash). The scanner regex
* /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash.
* Fix: add bare word-boundary replacement.
*
* SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/
* directory. Codex installs .toml agent files, so they are invisible to the
* manifest and thus to any manifest-based scan fix.
* Fix: also check .toml.
*/
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 { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const {
install,
convertClaudeCommandToCodexSkill,
} = require('../bin/install.js');
const { createTempDir, cleanup, captureConsole } = 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;
}
}
describe('#570 — Codex leak scanner sub-bugs', { 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-570-');
codexHome = path.join(tmpRoot, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
// SUB-BUG B
test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => {
// convertClaudeToCodexMarkdown is not exported directly; exercise it via
// convertClaudeCommandToCodexSkill which calls it internally.
const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude';
const out = convertClaudeCommandToCodexSkill(input, 'gsd-test');
assert.ok(
!/(?:~|\$HOME)\/\.claude\b/.test(out),
`Expected no leaked ~/.claude reference after conversion, got:\n${out}`,
);
});
// SUB-BUG C
test('writeManifest includes .toml agent files for Codex', () => {
withCodexHome(codexHome, () => install(true, 'codex'));
const agentsDir = path.join(codexHome, 'agents');
// Not the shared listAgentFiles() helper: this reads the INSTALLED Codex
// dest dir and filters .toml (not source .md), so its semantics differ.
// Confirm that Codex actually wrote .toml agent files — if none exist the
// test is vacuous and we should fail loudly.
const tomlFiles = fs.existsSync(agentsDir)
? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml'))
: [];
assert.ok(
tomlFiles.length > 0,
`Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`,
);
const manifestPath = path.join(codexHome, 'gsd-file-manifest.json');
assert.ok(
fs.existsSync(manifestPath),
`gsd-file-manifest.json must exist after install; not found at ${manifestPath}`,
);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestKeys = Object.keys(manifest.files || {});
const tomlManifestKeys = manifestKeys.filter(
(k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'),
);
assert.ok(
tomlManifestKeys.length > 0,
`Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` +
`agents/ toml files on disk: ${tomlFiles.join(', ')}\n` +
`All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`,
);
});
// SUB-BUG A
test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => {
// Write a pre-existing file with ~/.claude references BEFORE install.
const memoriesDir = path.join(codexHome, 'memories');
fs.mkdirSync(memoriesDir, { recursive: true });
const preExistingFile = path.join(memoriesDir, 'raw_memories.md');
fs.writeFileSync(
preExistingFile,
'# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n',
);
let captured;
withCodexHome(codexHome, () => {
captured = captureConsole(() => install(true, 'codex'));
});
const combinedOutput = captured.stderr;
assert.ok(
!combinedOutput.includes('memories/raw_memories.md'),
`scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` +
`Actual warnings:\n${combinedOutput}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-704-codex-launcher-path-corruption.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-704-codex-launcher-path-corruption (consolidation epic #1969 B1 #1970)", () => {
// allow-test-rule: source-text-is-the-product (see #704)
'use strict';
/**
* Regression test for issue #704:
* "v1.3.1 global install ships literal $gsd-core launcher paths in workflows"
*
* ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex
* /(?<![a-zA-Z0-9./])\/gsd-([a-z0-9-]+)/
* The lookbehind did NOT include `}`, so shell variable expressions like
* `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`
* had their `/gsd-core` matched (the char before `/` was `}`, not in the
* exclusion set), converting it to `$gsd-core` and breaking all Codex
* workflow launcher paths.
*
* FIX: Add `}` to the lookbehind set so `${VAR}/gsd-core/` is excluded.
*/
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 {
convertClaudeCommandToCodexSkill,
convertSlashCommandsToCodexSkillMentions,
} = require('../bin/install.js');
// The canonical launcher snippet path that was being corrupted
const RUNTIME_ROOT_PATH = '${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}';
// The exact bad token reported in issue #704
const BAD_TOKEN = '$gsd-core';
describe('#704 — Codex global install launcher path corruption', () => {
test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => {
// Minimal fixture with the launcher snippet and command-substitution patterns
// that were being corrupted (#704).
const input = [
'---',
'description: Test skill',
'---',
'',
'```bash',
'_GSD_SHIM_NAME="gsd-tools.cjs"',
'_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"',
'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"',
'if [ -f "$GSD_TOOLS" ]; then',
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then',
' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"',
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then',
' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"',
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
'fi',
'# Command-substitution path form (reapply-patches pattern)',
'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"',
'```',
].join('\n');
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704');
// Shell-context corruption patterns from issue #704:
// - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*`
// - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*`
const shellCorruptionPatterns = [
{ pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' },
{ pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' },
];
for (const { pattern, description } of shellCorruptionPatterns) {
assert.ok(
!output.includes(pattern),
`Codex skill conversion must not produce "${pattern}" (${description}). ` +
`Offending fragment: ${
output.includes(pattern)
? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80)
: '(not found)'
}`,
);
}
// The correct path forms must be preserved — the canonical launcher path
// (RUNTIME_ROOT_PATH) must survive Codex conversion intact.
assert.ok(
output.includes(RUNTIME_ROOT_PATH),
`Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` +
`Got:\n${output.substring(0, 500)}`,
);
assert.ok(
output.includes(')/gsd-local-patches'),
`Expected ")/gsd-local-patches" to appear in the converted output. ` +
`Got:\n${output.substring(0, 500)}`,
);
});
test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => {
// All these paths appear after a shell-closing character (} or )) and must
// NOT be converted to $gsd-* by the Codex slash-command converter.
const shellPaths = [
// Shell variable expression forms (} closer)
{ path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
{ path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
{ path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
// Command-substitution forms () closer) — reapply-patches pattern
{ path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' },
{ path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' },
];
for (const { path: p, corruptedForm } of shellPaths) {
const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``;
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths');
assert.ok(
!output.includes(corruptedForm),
`Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` +
`Got:\n${output}`,
);
}
});
test('convertClaudeCommandToCodexSkill still converts legitimate /gsd-<cmd> slash mentions', () => {
// Slash-command mentions (not preceded by }) should still be converted
const input = [
'---',
'description: Test',
'---',
'',
'Use /gsd-discuss-phase to start a discussion.',
'Or use /gsd-plan-phase for planning.',
'Also: /gsd:capture --backlog adds items.',
].join('\n');
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds');
assert.ok(
output.includes('$gsd-discuss-phase'),
'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase',
);
assert.ok(
output.includes('$gsd-plan-phase'),
'Expected /gsd-plan-phase to be converted to $gsd-plan-phase',
);
assert.ok(
output.includes('$gsd-capture'),
'Expected /gsd:capture to be converted to $gsd-capture',
);
});
test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => {
// Walk gsd-core/workflows/ and assert that no file produces $gsd-core
// inside a shell variable expansion context after Codex conversion.
//
// NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`)
// was a pre-existing gap with the #704 lookbehind fix and is now addressed by
// the positive-boundary regex introduced in #712. That case is covered by the
// "#712" describe block below.
//
// We probe for the specific shell-context pattern from the issue report:
// BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/
// GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/
const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows');
if (!fs.existsSync(workflowsDir)) {
// If the directory doesn't exist, skip gracefully (non-standard layout)
return;
}
const files = fs.readdirSync(workflowsDir)
.filter((f) => f.endsWith('.md'))
.map((f) => path.join(workflowsDir, f));
assert.ok(files.length > 0, 'Expected at least one workflow .md file');
// Shell-context corruption patterns from issue #704:
// - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions
// - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions
const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/;
const offending = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const skillName = `gsd-${path.basename(file, '.md')}`;
const converted = convertClaudeCommandToCodexSkill(content, skillName);
const match = converted.match(SHELL_CORRUPTION_RE);
if (match) {
const idx = converted.indexOf(match[0]);
offending.push({
file: path.relative(workflowsDir, file),
context: converted.substring(Math.max(0, idx - 40), idx + 80),
});
}
}
assert.deepStrictEqual(
offending,
[],
`Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` +
offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'),
);
});
test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => {
// Walk commands/gsd/ and assert that no command file produces the shell-context
// }$gsd-core corruption — since commands also go through
// convertClaudeCommandToCodexSkill when installed globally for Codex.
const commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
if (!fs.existsSync(commandsDir)) return;
const files = fs.readdirSync(commandsDir)
.filter((f) => f.endsWith('.md'))
.map((f) => path.join(commandsDir, f));
assert.ok(files.length > 0, 'Expected at least one command .md file');
const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/;
const offending = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const skillName = `gsd-${path.basename(file, '.md')}`;
const converted = convertClaudeCommandToCodexSkill(content, skillName);
const match = converted.match(SHELL_CORRUPTION_RE);
if (match) {
const idx = converted.indexOf(match[0]);
offending.push({
file: path.relative(commandsDir, file),
context: converted.substring(Math.max(0, idx - 40), idx + 80),
});
}
}
assert.deepStrictEqual(
offending,
[],
`Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` +
offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'),
);
});
});
describe('#712: positive-boundary slash-command conversion', () => {
// Tests call convertSlashCommandsToCodexSkillMentions directly so the regex
// is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE
// stripping, no .claude→.codex rewrite masking the result.
// ── MUST-NOT-CONVERT (negative) cases ─────────────────────────────────────
// These inputs must be returned UNCHANGED — no $gsd-* substitution.
test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => {
const input = 'See `/gsd-core/workflows/update.md` for details.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected backtick-wrapped path to be unchanged. Got: ${result}`,
);
});
test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => {
const input = 'Run `/gsd-pi/bin/foo.cjs` directly.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected deep backtick-wrapped path to be unchanged. Got: ${result}`,
);
});
test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => {
const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes('$gsd-core'),
`Expected no $gsd-core substitution in shell var path. Got: ${result}`,
);
assert.ok(
result.includes('/gsd-core/bin/x'),
`Expected original path to be preserved. Got: ${result}`,
);
});
test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => {
const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes(')$gsd-local'),
`Expected no )$gsd-local substitution. Got: ${result}`,
);
assert.ok(
result.includes(')/gsd-local-patches'),
`Expected original path to be preserved. Got: ${result}`,
);
});
test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => {
const input = 'node bin/gsd-tools.cjs --help';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.strictEqual(
result,
input,
`Expected plain path segment to be unchanged. Got: ${result}`,
);
});
test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => {
// Tests the regex in isolation: the .claude→.codex path rewrite that happens
// inside convertClaudeToCodexMarkdown does NOT run here. We assert directly
// that the slash-command regex leaves /gsd-core after the slash intact —
// i.e. the `e` in `/gsd-core` is NOT treated as a command boundary.
const input = 'Look in .claude/gsd-core/agents for the agent files.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
!result.includes('$gsd-core'),
`Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`,
);
assert.ok(
result.includes('/gsd-core/agents'),
`Expected /gsd-core/agents to remain as a path segment. Got: ${result}`,
);
});
// ── MUST-CONVERT (positive) cases ─────────────────────────────────────────
// These inputs contain legitimate /gsd-<cmd> mentions that MUST be converted.
test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => {
const input = 'Use /gsd-discuss-phase to start.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('$gsd-discuss-phase'),
`Expected /gsd-discuss-phase to be converted. Got: ${result}`,
);
assert.ok(
!result.includes('/gsd-discuss-phase'),
`Expected original /gsd-discuss-phase to be replaced. Got: ${result}`,
);
});
test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => {
// A backtick-wrapped COMMAND (single segment, no path continuation) MUST
// still be converted — this guards against a naive whitespace-only fix.
const input = 'Run `/gsd-execute-phase` now.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('`$gsd-execute-phase`'),
`Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`,
);
assert.ok(
!result.includes('`/gsd-execute-phase`'),
`Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`,
);
});
test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => {
const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('`$gsd-plan-phase`'),
`Expected /gsd-plan-phase to be converted. Got: ${result}`,
);
assert.ok(
result.includes('`$gsd-progress`'),
`Expected /gsd-progress to be converted. Got: ${result}`,
);
});
test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => {
// This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline
// (the body always has preceding chars). Direct call exercises the ^ branch.
const input = '/gsd-manager runs the pipeline.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('$gsd-manager'),
`Expected /gsd-manager to be converted. Got: ${result}`,
);
assert.ok(
!result.includes('/gsd-manager'),
`Expected original /gsd-manager to be replaced. Got: ${result}`,
);
});
test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => {
const input = 'Call "/gsd-resume" to continue.';
const result = convertSlashCommandsToCodexSkillMentions(input);
assert.ok(
result.includes('"$gsd-resume"'),
`Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`,
);
assert.ok(
!result.includes('"/gsd-resume"'),
`Expected original "/gsd-resume" to be replaced. Got: ${result}`,
);
});
// ── End-to-end: headline #712 bug through the real install pipeline ────────
test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => {
// Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above)
// to prove the real install path does not corrupt prose references to repo paths.
const input = [
'---',
'description: Test',
'---',
'',
'See `/gsd-core/workflows/update.md` for the update workflow.',
].join('\n');
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e');
assert.ok(
!output.includes('$gsd-core'),
`Expected no $gsd-core in converted output. Got:\n${output}`,
);
assert.ok(
output.includes('/gsd-core/workflows/update.md'),
`Expected backtick-wrapped path to survive conversion. Got:\n${output}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-851-codex-quick-adapter-agent-type-fallback (consolidation epic #1969 B1 #1970)", () => {
// allow-test-rule: source-text-is-the-product (see #851)
// Tests assert on text in bin/install.js (Codex adapter header prose) —
// the adapter text IS the product loaded by Codex agents at runtime.
'use strict';
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');
// Helper: extract Section C from the raw source text.
// Anchors on the heading and ends at </codex_skill_adapter>.
function getSectionC() {
// allow-test-rule: source-text-is-the-product (see #851)
const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); // allow-test-rule: source-text-is-the-product (see #851)
assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js');
const closeTag = src.indexOf('</codex_skill_adapter>', headingIdx); // allow-test-rule: source-text-is-the-product (see #851)
assert.ok(closeTag >= 0, 'Section C must be followed by </codex_skill_adapter>');
return src.slice(headingIdx, closeTag);
}
describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => {
// (a) Schema-detection step: the adapter must require the agent to inspect
// spawn_agent's parameter schema BEFORE deciding how to dispatch.
test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => {
const sectionC = getSectionC();
// Must name BOTH schema variants so the agent knows what to look for
assert.ok(
sectionC.includes('multi_agent_v1'),
'Section C must name the multi_agent_v1 schema to identify the limited form',
);
assert.ok(
sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'),
'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form',
);
// Must instruct schema inspection before spawning
assert.ok(
sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'),
'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)',
);
// All three requirements together (AND):
assert.ok(
sectionC.includes('multi_agent_v1') &&
(sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) &&
(sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')),
'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning',
);
});
// (b) Active-config-root resolution: the TOML path must describe how to
// resolve the config root (honoring $CODEX_HOME / --config-dir / --local),
// not imply a single fixed path.
test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => {
const sectionC = getSectionC();
// Must mention the agents/<agent-name>.toml relative path
assert.ok(
sectionC.includes('agents/<agent-name>.toml'),
'Section C must reference agents/<agent-name>.toml for the TOML extraction step',
);
// Must describe dynamic config-root resolution (at least two of the three
// override mechanisms, plus the word "config" to anchor context)
const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME');
const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir');
const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local');
const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory');
assert.ok(
mentionsCodexHome,
'Section C fallback must mention $CODEX_HOME for config-root resolution',
);
assert.ok(
mentionsConfigDir,
'Section C fallback must mention --config-dir for config-root resolution',
);
assert.ok(
mentionsLocal,
'Section C fallback must mention --local / .codex for config-root resolution',
);
assert.ok(
mentionsConfigRoot,
'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)',
);
// AND: all four required elements together
assert.ok(
mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot,
'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)',
);
// Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites
// and cause bug-3582 to diverge)
assert.ok(
!sectionC.includes('~/.codex/'),
'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)',
);
});
// (c) "NOT equivalent" label: the workaround must be explicitly labeled as
// not equivalent to typed gsd-planner/gsd-executor execution.
test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => {
const sectionC = getSectionC();
// Must name at least one typed agent
const namesTypedAgent =
sectionC.includes('gsd-planner') ||
sectionC.includes('gsd-executor') ||
sectionC.includes('typed GSD agent') ||
sectionC.includes('typed gsd-');
// Must contain explicit "not equivalent" / "NOT equivalent" / negation language
const hasNotEquivalent =
sectionC.toLowerCase().includes('not equivalent') ||
sectionC.includes('NOT equivalent') ||
sectionC.includes('is NOT possible');
// Must name the workaround as a workaround, not a first-class path
const hasWorkaroundLabel =
sectionC.includes('workaround') ||
sectionC.includes('fallback');
assert.ok(
namesTypedAgent,
'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")',
);
assert.ok(
hasNotEquivalent,
'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path',
);
assert.ok(
hasWorkaroundLabel,
'Section C must label the generic-agent path as a workaround or fallback',
);
// AND: all three together
assert.ok(
namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel,
'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback',
);
});
// (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must
// instruct the agent to fail closed and report the limitation, not silently degrade.
test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => {
const sectionC = getSectionC();
const hasFailClosed =
sectionC.includes('fail closed') ||
sectionC.includes('fail-closed') ||
sectionC.includes('fail_closed');
const hasReportLimitation =
sectionC.includes('schema limitation') ||
sectionC.includes('report') ||
sectionC.includes('not silently') ||
sectionC.includes('silently degrading') ||
sectionC.includes('silently');
const hasMandatoryContext =
sectionC.includes('mandatory') ||
sectionC.includes('required') ||
sectionC.includes('worktree isolation') ||
sectionC.includes('isolation');
assert.ok(
hasFailClosed,
'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)',
);
assert.ok(
hasReportLimitation,
'Section C must instruct reporting the schema limitation rather than silently degrading',
);
assert.ok(
hasMandatoryContext,
'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)',
);
// AND: all three together
assert.ok(
hasFailClosed && hasReportLimitation && hasMandatoryContext,
'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts',
);
});
// Regression guard: typed mapping for capable schema must still be present.
test('adapter still documents typed agent_type spawn for sessions that support it', () => {
const sectionC = getSectionC();
assert.ok(
sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'),
'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)',
);
assert.ok(
sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'),
'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions',
);
});
// Regression guard: deferred tool discovery must remain (bug-279 contract).
test('adapter deferred tool discovery instruction is preserved', () => {
// The pre-existing bug-279 contract must remain intact
// allow-test-rule: source-text-is-the-product (see #851)
assert.ok(
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product (see #851)
'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-772-codex-hook-events.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-772-codex-hook-events (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Enhancement #772: Adopt new stable Codex hook events + commandWindows for
* Windows parity.
*
* Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite
* asserts that a Codex install:
*
* (a) Registers the 3 new high-value hook events in hooks.json:
* - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
* - Stop — post-session context headroom tracking
* - PostToolUse — mirror the Claude Code PostToolUse context monitor
*
* (b) Emits `commandWindows` in the SessionStart hooks.json entry so that
* Windows users get the .cmd shim path and non-Windows users get the POSIX
* node runner command. Both fields are present in the same entry; Codex picks
* the right one per its HookHandlerConfig schema
* (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias).
*
* Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the
* gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be
* a silent no-op for the UserPromptSubmit payload shape).
*
* Test strategy:
* - Test new event registration via ensureCodexHooksJsonEvent() directly
* (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart
* directly with a stub hook file — avoids full install() migration dance).
* - Test commandWindows via ensureCodexHooksJsonSessionStart() directly.
* - IR-first discipline: assert on the structured result, not rendered text.
*
* Verified hook event schema:
* https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs
* https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
ensureCodexHooksJsonSessionStart,
ensureCodexHooksJsonEvent,
removeCodexHooksJsonEvent,
reconcileCodexHooksJsonEvent,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Extract all hook handler entries (full objects with type/command/etc.) for
* `eventName` from a hooks.json object (flat or nested-hooks shape).
*/
function hooksJsonHandlersForEvent(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(entry =>
Array.isArray(entry && entry.hooks) ? entry.hooks : []
);
}
function readHooksJson(targetDir) {
const p = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function stubHookFile(targetDir, hookName) {
const hooksDest = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDest, { recursive: true });
const dest = path.join(hooksDest, hookName);
if (!fs.existsSync(dest)) {
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
}
}
// ─── Suite 1: ensureCodexHooksJsonEvent export surface ───────────────────────
describe('enh-772: export surface — new functions are exported', () => {
test('ensureCodexHooksJsonEvent is a function', () => {
assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function',
'ensureCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs');
});
test('removeCodexHooksJsonEvent is a function', () => {
assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function',
'removeCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs');
});
test('reconcileCodexHooksJsonEvent is a function', () => {
assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function',
'reconcileCodexHooksJsonEvent must be exported from runtime-hooks-surface.cjs');
});
});
// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ─────────────────
describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-772-events-');
stubHookFile(tmpDir, 'gsd-context-monitor.js');
});
afterEach(() => {
cleanup(tmpDir);
});
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => {
const fakeRunner = '"/usr/local/bin/node"';
const result = ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
assert.ok(result && result.path, `result must have path for ${eventName}`);
assert.ok(result.wrote || result.changed,
`ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`);
assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')),
`hooks.json must exist after registering ${eventName}`);
});
test(`${eventName}: hooks.json contains the event entry`, () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
assert.ok(handlers.length > 0,
`Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`);
});
test(`${eventName}: hook entry uses gsd-context-monitor`, () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
assert.ok(
handlers.some(h => h.command && h.command.includes('gsd-context-monitor')),
`${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}`
);
});
test(`${eventName}: hook entry has type: 'command'`, () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
assert.strictEqual(entry && entry.type, 'command',
`${eventName} hook entry must have type 'command'`);
});
test(`${eventName}: hook entry has timeout: 10`, () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
assert.strictEqual(entry && entry.timeout, 10,
`${eventName} hook entry must have timeout 10`);
});
}
test('null absoluteRunner returns unchanged result without writing', () => {
const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
absoluteRunner: null,
platform: 'linux',
});
assert.strictEqual(result.changed, false,
'null runner must return changed: false');
assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')),
'hooks.json must NOT be written when runner is null');
});
});
// ─── Suite 3: commandWindows parity in SessionStart ──────────────────────────
describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-772-cmdwin-');
stubHookFile(tmpDir, 'gsd-check-update.js');
});
afterEach(() => {
cleanup(tmpDir);
});
// commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also
// written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows
// Codex at a non-existent .cmd file (the shim is only present after a native Windows
// install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync).
test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => {
const fakeRunner = '"/usr/local/bin/node"';
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
assert.ok(result && result.wrote, 'must write hooks.json on linux');
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`);
const entry = handlers[0];
assert.ok(
entry.commandWindows === undefined,
`commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}`
);
});
test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonSessionStart(tmpDir, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
const entry = handlers[0];
assert.ok(
entry.command && entry.command.includes('gsd-check-update'),
`POSIX command must reference gsd-check-update; got: ${entry.command}`
);
assert.ok(
!entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'),
`POSIX command must not end with .cmd; got: ${entry.command}`
);
});
test('null absoluteRunner: no commandWindows emitted, no write', () => {
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
absoluteRunner: null,
platform: 'linux',
});
assert.strictEqual(result.changed, false, 'null runner must return changed: false');
const hooksJson = readHooksJson(tmpDir);
if (hooksJson) {
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
for (const h of handlers) {
assert.ok(!h.commandWindows,
`commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`);
}
}
});
test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => {
// On win32, both `command` and `commandWindows` use the .cmd shim path
// (because managedCommand = shimIR.hookCommand = .cmd path, and
// commandWindows = same .cmd path). This ensures Codex picks the .cmd
// on Windows regardless of which field it reads.
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
absoluteRunner: fakeRunner,
platform: 'win32',
});
// The shim write and hooks.json write should succeed in the tmp dir.
if (result.wrote) {
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
assert.ok(handlers.length > 0,
`SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`);
const entry = handlers[0];
assert.ok(typeof entry.commandWindows === 'string',
`commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`);
// commandWindows should reference the .cmd shim
assert.ok(
entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'),
`commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}`
);
}
});
});
// ─── Suite 4: idempotency ────────────────────────────────────────────────────
describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-772-idem-');
stubHookFile(tmpDir, 'gsd-context-monitor.js');
});
afterEach(() => {
cleanup(tmpDir);
});
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
test(`${eventName}: calling twice does not duplicate hook entries`, () => {
const fakeRunner = '"/usr/local/bin/node"';
const opts = { absoluteRunner: fakeRunner, platform: 'linux' };
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
const hooksJson = readHooksJson(tmpDir);
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
assert.strictEqual(handlers.length, 1,
`${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`);
});
}
});
// ─── Suite 5: removeCodexHooksJsonEvent ──────────────────────────────────────
describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-772-remove-');
stubHookFile(tmpDir, 'gsd-context-monitor.js');
});
afterEach(() => {
cleanup(tmpDir);
});
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => {
const fakeRunner = '"/usr/local/bin/node"';
ensureCodexHooksJsonEvent(tmpDir, eventName, {
absoluteRunner: fakeRunner,
platform: 'linux',
});
// Verify it was registered
let hooksJson = readHooksJson(tmpDir);
let handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
assert.ok(handlers.length > 0, `${eventName} must be registered before removal`);
// Remove
const result = removeCodexHooksJsonEvent(tmpDir, eventName);
assert.ok(result.changed || result.wrote,
`removeCodexHooksJsonEvent must change hooks.json for ${eventName}`);
hooksJson = readHooksJson(tmpDir);
if (hooksJson) {
handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
assert.strictEqual(handlers.length, 0,
`After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`);
}
});
}
});
// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ─────────────
describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-772-preserve-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => {
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
const userEntry = {
hooks: [{ type: 'command', command: 'my-custom-hook.sh' }]
};
fs.writeFileSync(hooksJsonPath, JSON.stringify({
SubagentStart: [userEntry]
}, null, 2) + '\n');
reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"',
});
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
const table = hooksJson.hooks || hooksJson;
const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : [];
// Should have 2 entries: user entry + GSD entry
assert.ok(entries.length >= 2,
`User entry must be preserved; got entries: ${JSON.stringify(entries)}`);
// User entry must still be present
const userEntryStillPresent = entries.some(e =>
Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh')
);
assert.ok(userEntryStillPresent,
`User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2866-codex-strip-no-trailing-newline.test.cjs — consolidation epic #1969 (B8 #1977)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2866-codex-strip-no-trailing-newline (consolidation epic #1969 B8 #1977)", () => {
/**
* Bug #2866: Codex Installer (RC.7) fails to strip legacy flat hooks if
* trailing newline is missing.
*
* The cleanup regexes in `bin/install.js` matched stale GSD hook blocks
* via `\r?\n` at the end. When a stale block sat at end-of-file without
* a trailing newline (very common — many editors strip them, and the
* legacy installer never wrote one), no shape stripped, the installer
* saw `gsd-check-update` already present, skipped writing the new
* Nested-AoT block, and Codex 0.125+ refused to load with
* "invalid type: map, expected a sequence in `hooks`"
*
* Fix: every shape's terminator is now `(?:\r?\n|$)` so end-of-file
* counts as a valid terminator. The strip logic was lifted into a pure
* helper, `stripStaleGsdHookBlocks(configContent)`, exported from
* `bin/install.js` for direct test coverage.
*
* This test parses `package.json` to require `bin/install.js`
* structurally (not by hardcoded path), then drives each historical
* shape through the helper twice — once with a trailing newline, once
* without — and asserts both are stripped.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const REPO_ROOT = path.join(__dirname, '..');
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8'));
const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']);
const { stripStaleGsdHookBlocks } = require(installPath);
/**
* Parse the TOML output line-structurally so assertions check shape, not
* substring presence in raw text. Comments are dropped, table headers are
* recorded, and string-valued keys are captured. Sufficient for the small,
* well-formed TOML produced by these tests.
*/
function parseTomlShape(text) {
const tableHeaders = [];
const keys = new Map(); // dotted path → string value (last-write-wins, fine for these inputs)
let currentTable = '';
for (const rawLine of text.split('\n')) {
const line = rawLine.replace(/(?:^|\s)#.*$/, '').trim();
if (!line) continue;
const tableMatch = line.match(/^\[(\[)?([^\]]+)\]?\]$/);
if (tableMatch) {
currentTable = tableMatch[2];
tableHeaders.push((tableMatch[1] ? '[[' : '[') + currentTable + (tableMatch[1] ? ']]' : ']'));
continue;
}
const kvMatch = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/);
if (kvMatch) {
const key = currentTable ? `${currentTable}.${kvMatch[1]}` : kvMatch[1];
const value = kvMatch[2].replace(/^"(.*)"$/, '$1');
keys.set(key, value);
}
}
return { tableHeaders, keys };
}
const SHAPES = {
'Shape 1 (legacy gsd-update-check)': [
'# GSD Hooks',
'[[hooks]]',
'event = "SessionStart"',
'command = "node /Users/USER/.codex/hooks/gsd-update-check.js"',
].join('\n'),
'Shape 2 (flat [[hooks]] + gsd-check-update)': [
'# GSD Hooks',
'[[hooks]]',
'event = "SessionStart"',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
].join('\n'),
'Shape 3 ([[hooks.SessionStart]] without nested .hooks)': [
'# GSD Hooks',
'[[hooks.SessionStart]]',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
].join('\n'),
'Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])': [
'# GSD Hooks',
'[[hooks.SessionStart]]',
'',
'[[hooks.SessionStart.hooks]]',
'type = "command"',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
].join('\n'),
};
describe('bug-2866: stripStaleGsdHookBlocks handles end-of-file without trailing newline', () => {
test('stripStaleGsdHookBlocks is exported from bin/install.js', () => {
assert.strictEqual(typeof stripStaleGsdHookBlocks, 'function',
'bin/install.js must export stripStaleGsdHookBlocks');
});
function assertStripped(out, shape, scenario) {
const shape_ = parseTomlShape(out);
const hooksTable = shape_.tableHeaders.find((h) => /^\[\[?hooks(\.|]\])/.test(h));
assert.strictEqual(hooksTable, undefined,
`(${shape}, ${scenario}) no hooks table header may remain after strip, got tables: ${shape_.tableHeaders.join(', ')}`);
const staleCmd = [...shape_.keys.entries()].find(([_, v]) =>
/gsd-(update-check|check-update)/.test(v));
assert.strictEqual(staleCmd, undefined,
`(${shape}, ${scenario}) no key may carry a stale gsd-*-update command, got: ${staleCmd && staleCmd.join('=')}`);
assert.strictEqual(shape_.keys.get('history.persistence'), 'save-all',
`(${shape}, ${scenario}) history.persistence must be preserved as "save-all"`);
}
for (const [shape, block] of Object.entries(SHAPES)) {
test(`${shape}: stripped when terminated by trailing newline`, () => {
const input = `[history]\npersistence = "save-all"\n${block}\n`;
assertStripped(stripStaleGsdHookBlocks(input), shape, 'with trailing newline');
});
test(`${shape}: stripped when at end-of-file without trailing newline`, () => {
// The reporter's repro: stale block sits at the very end with no \n.
const input = `[history]\npersistence = "save-all"\n${block}`;
assertStripped(stripStaleGsdHookBlocks(input), shape, 'no trailing newline');
});
}
test('returns input unchanged when no GSD hook block is present', () => {
const benign = '[history]\npersistence = "save-all"\n';
const out = stripStaleGsdHookBlocks(benign);
assert.strictEqual(out, benign, 'helper must be a no-op when no GSD reference exists');
const benignShape = parseTomlShape(out);
assert.strictEqual(benignShape.keys.get('history.persistence'), 'save-all',
'parsed shape must preserve history.persistence');
assert.deepStrictEqual(benignShape.tableHeaders, ['[history]'],
'parsed shape must contain only the [history] table');
});
// The structural rewrite (TOML-AST-driven, not regex-driven) must handle
// whitespace and key-ordering variations that the previous regex missed.
// These cases were silently leaked by the old implementation; one
// (V3) actually corrupted the file by leaving an orphaned key=value line
// outside any table.
const VARIATIONS = {
'extra blank line in Shape 4': [
'# GSD Hooks',
'[[hooks.SessionStart]]',
'',
'',
'[[hooks.SessionStart.hooks]]',
'type = "command"',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
].join('\n'),
'keys reordered (command before event in Shape 2)': [
'# GSD Hooks',
'[[hooks]]',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
'event = "SessionStart"',
].join('\n'),
'extra key alongside command (Shape 3 + timeout)': [
'# GSD Hooks',
'[[hooks.SessionStart]]',
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
'timeout = 5000',
].join('\n'),
'tight whitespace (no spaces around `=`)': [
'# GSD Hooks',
'[[hooks]]',
'event="SessionStart"',
'command="node /Users/USER/.codex/hooks/gsd-check-update.js"',
].join('\n'),
};
for (const [variation, block] of Object.entries(VARIATIONS)) {
test(`variation stripped: ${variation}`, () => {
const input = `[history]\npersistence = "save-all"\n${block}\n`;
assertStripped(stripStaleGsdHookBlocks(input), variation, 'with trailing newline');
});
test(`variation stripped at EOF without trailing newline: ${variation}`, () => {
const input = `[history]\npersistence = "save-all"\n${block}`;
assertStripped(stripStaleGsdHookBlocks(input), variation, 'no trailing newline');
});
}
test('user-authored [[hooks.UserPromptSubmit]] is preserved', () => {
// The structural strip must not touch hook tables that don't carry a
// GSD-managed `gsd-(check-update|update-check).js` command.
const input = [
'[history]',
'persistence = "save-all"',
'[[hooks.UserPromptSubmit]]',
'command = "node /Users/USER/my-hook.js"',
'',
].join('\n');
const out = stripStaleGsdHookBlocks(input);
const shape = parseTomlShape(out);
assert.ok(
shape.tableHeaders.includes('[[hooks.UserPromptSubmit]]'),
`user-authored [[hooks.UserPromptSubmit]] must survive, got: ${shape.tableHeaders.join(', ')}`,
);
assert.strictEqual(
shape.keys.get('hooks.UserPromptSubmit.command'),
'node /Users/USER/my-hook.js',
'user-authored command value must be preserved verbatim',
);
});
test('Shape 4 strip does not leave an orphaned [[hooks.SessionStart]] header', () => {
// Shape 4 is stripped before Shape 3 specifically to avoid this.
const block = SHAPES['Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])'];
const out = stripStaleGsdHookBlocks(`[history]\npersistence = "save-all"\n${block}`);
const outShape = parseTomlShape(out);
const orphan = outShape.tableHeaders.find((h) => /hooks\.SessionStart/.test(h));
assert.strictEqual(orphan, undefined,
`Shape 4 strip must remove the parent [[hooks.SessionStart]] header too, got tables: ${outShape.tableHeaders.join(', ')}`);
});
});
});
}
// ─── #2586: stop installing Codex context-monitor hooks without metrics ────
// gsd-context-monitor.js reads a statusline bridge file Codex never writes,
// so every registered event was a guaranteed silent no-op. These tests drive
// the REAL install()/uninstall() entry points against a real temp CODEX_HOME
// — never a hand-fabricated manifest (see PR #2709's Blocker 1: its cleanup
// path was unreachable in production because its tests only exercised a
// fixture, not real installer state).
describe('#2586 Codex context-monitor: stop installing, clean up on reinstall', () => {
const {
cleanupOrphanedCodexContextMonitorScript,
isGsdOwnedCodexContextMonitorScript,
hooksJsonReferencesCodexContextMonitor,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
let codexHome;
beforeEach(() => {
codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-'));
});
afterEach(() => {
cleanup(codexHome);
delete process.env.GSD_ALLOW_SYMLINKED_DEST;
});
function hooksJsonPath(home) {
return path.join(home, 'hooks.json');
}
function readHooksJson(home) {
const raw = fs.readFileSync(hooksJsonPath(home), 'utf8');
return JSON.parse(raw);
}
function monitorScriptPath(home) {
return path.join(home, 'hooks', 'gsd-context-monitor.js');
}
function monitorCmdShimPath(home) {
return path.join(home, 'hooks', 'gsd-context-monitor.cmd');
}
// uninstall(), unlike install(), does not sandbox CODEX_HOME/HOME itself —
// runCodexInstall's own env-restore in its `finally` means those are back
// to the real environment by the time a bare `uninstall(true, 'codex')`
// would run. Mirrors runCodexInstall's own sandboxing exactly so uninstall
// operates on the temp fixture, never the real ~/.codex.
function runCodexUninstall(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;
process.env.HOME = codexHome;
process.env.USERPROFILE = codexHome;
try {
process.chdir(cwd);
return uninstall(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;
}
}
// The exact shape a real pre-#2586 install would have written: the shipped
// gsd-context-monitor.js content (with its ownership markers intact) plus
// hooks.json registrations for every CODEX_EXTENDED_HOOK_EVENTS member,
// using the same command-projection shape ensureCodexHooksJsonEvent used
// to write. Built from the real resolveNodeRunner() output, not a literal
// guess at the command string, so a drift in projectManagedHookCommand's
// output shape cannot make this fixture silently stop matching reality.
function seedPreExisting2586Install(home) {
fs.mkdirSync(path.join(home, 'hooks'), { recursive: true });
// A literal fixture carrying the same ownership markers
// isGsdOwnedCodexContextMonitorScript looks for, built as a string
// (never read+string-matched from the real shipped source file — see
// this repo's "no source grep" test rule).
const fixtureContent = [
'#!/usr/bin/env node',
'// gsd-hook-version: 1.12.0',
'// Context Monitor - PostToolUse/AfterTool hook',
'process.exit(0);',
'',
].join('\n');
fs.writeFileSync(monitorScriptPath(home), fixtureContent, 'utf8');
const runner = resolveNodeRunner();
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
hooksSurface.ensureCodexHooksJsonEvent(home, eventName, {
absoluteRunner: runner,
platform: process.platform,
});
}
}
test('fresh install does not copy gsd-context-monitor.js or register any extended event', () => {
runCodexInstall(codexHome);
assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false,
'gsd-context-monitor.js must not be copied on a fresh Codex install');
assert.strictEqual(fs.existsSync(monitorCmdShimPath(codexHome)), false);
assert.strictEqual(fs.existsSync(path.join(codexHome, 'hooks', 'lib', 'hook-exit.js')), false,
'hook-exit.js is only required by gsd-context-monitor.js — nothing else staged should pull it in');
assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update.js')),
'gsd-check-update.js must still be staged');
assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'gsd-check-update-worker.js')));
assert.ok(fs.existsSync(path.join(codexHome, 'hooks', 'managed-hooks-registry.cjs')));
if (fs.existsSync(hooksJsonPath(codexHome))) {
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false);
}
});
test('reinstall removes exact pre-#2586 registrations for every extended event and deletes the orphaned script', () => {
runCodexInstall(codexHome);
seedPreExisting2586Install(codexHome);
assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'fixture sanity: script seeded');
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), true, 'fixture sanity: registered');
runCodexInstall(codexHome);
assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false,
'orphaned gsd-context-monitor.js must be removed once unreferenced and GSD-owned');
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false,
'no hooks.json entry may still reference gsd-context-monitor after reinstall');
// gsd-check-update's own SessionStart registration must survive untouched.
const hooks = readHooksJson(codexHome);
const sessionStart = (hooks.hooks && hooks.hooks.SessionStart) || [];
const hasCheckUpdate = sessionStart.some((entry) =>
(entry.hooks || []).some((h) => typeof h.command === 'string' && /gsd-check-update/.test(h.command)));
assert.ok(hasCheckUpdate, 'gsd-check-update SessionStart registration must remain after cleanup');
});
test('reinstall preserves a hand-customized registration and does not delete a still-referenced script', () => {
runCodexInstall(codexHome);
seedPreExisting2586Install(codexHome);
// Hand-edit ONE event's entry to a shape isManagedHookCommand will not
// recognize (wraps the invocation in a shell script it does not know).
const before = readHooksJson(codexHome);
before.hooks.Stop = [{ hooks: [{ type: 'command', command: 'bash -c "/opt/custom/my-wrapper.sh"' }] }];
fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(before, null, 2) + '\n', 'utf8');
runCodexInstall(codexHome);
const after = readHooksJson(codexHome);
assert.deepStrictEqual(after.hooks.Stop, before.hooks.Stop,
'a hand-customized registration must survive verbatim');
});
test('reinstall leaves unrelated hooks.json events and config.toml keys untouched', () => {
runCodexInstall(codexHome);
let hooks = fs.existsSync(hooksJsonPath(codexHome)) ? readHooksJson(codexHome) : { hooks: {} };
if (!hooks.hooks) hooks.hooks = {};
hooks.hooks.UnrelatedEvent = [{ hooks: [{ type: 'command', command: 'echo unrelated' }] }];
fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify(hooks, null, 2) + '\n', 'utf8');
const configPath = path.join(codexHome, 'config.toml');
const configBefore = fs.readFileSync(configPath, 'utf8') + '\n[my_unrelated_section]\nfoo = "bar"\n';
fs.writeFileSync(configPath, configBefore, 'utf8');
runCodexInstall(codexHome);
const after = readHooksJson(codexHome);
assert.deepStrictEqual(after.hooks.UnrelatedEvent, hooks.hooks.UnrelatedEvent,
'an unrelated event array must be untouched');
const configAfter = fs.readFileSync(configPath, 'utf8');
assert.ok(configAfter.includes('[my_unrelated_section]\nfoo = "bar"'),
'unrelated config.toml section must survive a Codex reinstall');
});
test('a user-owned pre-existing EMPTY event array is preserved, not deleted', () => {
runCodexInstall(codexHome);
fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: [] } }, null, 2) + '\n', 'utf8');
runCodexInstall(codexHome);
const after = readHooksJson(codexHome);
assert.ok(Array.isArray(after.hooks.Stop) && after.hooks.Stop.length === 0,
'an empty array the user already had must not be dropped by cleanup that found nothing GSD-owned to remove');
});
test('symlinked hooks.json aborts before any Codex change, without GSD_ALLOW_SYMLINKED_DEST', () => {
runCodexInstall(codexHome);
const configPath = path.join(codexHome, 'config.toml');
const configBefore = fs.readFileSync(configPath, 'utf8');
const realHooksJson = path.join(codexHome, 'real-hooks.json');
fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8');
fs.unlinkSync(hooksJsonPath(codexHome));
fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome));
assert.throws(() => runCodexInstall(codexHome), /symlink/i);
assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(),
'the symlink itself must survive an aborted install — never replaced by a plain file');
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), configBefore,
'config.toml must be restored to its pre-attempt snapshot on abort');
});
test('symlinked hooks.json is followed when GSD_ALLOW_SYMLINKED_DEST=1', () => {
runCodexInstall(codexHome);
const realHooksJson = path.join(codexHome, 'real-hooks.json');
fs.writeFileSync(realHooksJson, JSON.stringify({ hooks: {} }, null, 2) + '\n', 'utf8');
fs.unlinkSync(hooksJsonPath(codexHome));
fs.symlinkSync(realHooksJson, hooksJsonPath(codexHome));
process.env.GSD_ALLOW_SYMLINKED_DEST = '1';
assert.doesNotThrow(() => runCodexInstall(codexHome));
assert.ok(fs.lstatSync(hooksJsonPath(codexHome)).isSymbolicLink(), 'still a symlink afterward');
assert.ok(fs.existsSync(realHooksJson), 'the symlink target must have been written through');
});
test('cleanupOrphanedCodexContextMonitorScript keeps the hooks.json deregistration when script deletion fails', () => {
runCodexInstall(codexHome);
seedPreExisting2586Install(codexHome);
for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
require('../gsd-core/bin/lib/runtime-hooks-surface.cjs').removeCodexHooksJsonEvent(codexHome, eventName);
}
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false, 'deregistration committed first');
const originalUnlinkSync = fs.unlinkSync;
fs.unlinkSync = (target, ...rest) => {
if (typeof target === 'string' && target.includes('gsd-context-monitor')) {
throw Object.assign(new Error('EPERM: simulated'), { code: 'EPERM' });
}
return originalUnlinkSync.call(fs, target, ...rest);
};
// Determine which GSD-owned candidates actually exist BEFORE the mocked
// deletion attempt — on Windows, ensureCodexHooksJsonEvent also staged a
// .cmd shim alongside the .js file (see buildCodexHookWindowsShimIR), so
// both deletions fail under the mock above; on POSIX only the .js file
// exists. Asserting against this rather than a hardcoded 1 keeps the row
// meaningful on both platforms instead of just loosening it to "at least
// one" (see CI failure: Windows reported 2 warnings, not 1).
const cmdShimPath = monitorCmdShimPath(codexHome);
const cmdShimExisted = fs.existsSync(cmdShimPath);
let result;
try {
result = cleanupOrphanedCodexContextMonitorScript(codexHome);
} finally {
fs.unlinkSync = originalUnlinkSync;
}
const expectedWarningCount = cmdShimExisted ? 2 : 1;
assert.strictEqual(result.warnings.length, expectedWarningCount);
assert.ok(result.warnings.some((w) => /gsd-context-monitor\.js$/.test(w.path)),
'a warning must name the .js script');
if (cmdShimExisted) {
assert.ok(result.warnings.some((w) => /gsd-context-monitor\.cmd$/.test(w.path)),
'a warning must name the .cmd shim when Windows staged one');
assert.ok(fs.existsSync(cmdShimPath), 'the .cmd shim remains on disk since its deletion failed too');
}
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false,
'the already-safe hooks.json deregistration must not be reverted by a script-deletion failure');
assert.ok(fs.existsSync(monitorScriptPath(codexHome)), 'the file remains on disk since deletion failed');
});
test('isGsdOwnedCodexContextMonitorScript rejects a user file at the same path', () => {
runCodexInstall(codexHome);
fs.mkdirSync(path.join(codexHome, 'hooks'), { recursive: true });
fs.writeFileSync(monitorScriptPath(codexHome), '#!/usr/bin/env node\nconsole.log("my own script");\n', 'utf8');
assert.strictEqual(isGsdOwnedCodexContextMonitorScript(monitorScriptPath(codexHome)), false);
});
test('uninstall removes recognized registrations and the orphaned script symmetrically with install', () => {
runCodexInstall(codexHome);
seedPreExisting2586Install(codexHome);
runCodexUninstall(codexHome);
assert.strictEqual(fs.existsSync(monitorScriptPath(codexHome)), false);
if (fs.existsSync(hooksJsonPath(codexHome))) {
assert.strictEqual(hooksJsonReferencesCodexContextMonitor(codexHome), false);
}
});
test('uninstall does not throw on an unmodeled hooks.json event-value shape', () => {
runCodexInstall(codexHome);
fs.writeFileSync(hooksJsonPath(codexHome), JSON.stringify({ hooks: { Stop: 'not-an-array' } }, null, 2) + '\n', 'utf8');
assert.doesNotThrow(() => runCodexUninstall(codexHome));
});
test('property: reconcileCodexHooksJsonEvent never removes a non-managed-shape command', () => {
const { reconcileCodexHooksJsonEvent } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 40 }).filter((s) => !/gsd-context-monitor|gsd-check-update/.test(s)), { minLength: 1, maxLength: 5 }),
(customCommands) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-2586-prop-'));
try {
const seeded = { hooks: { Stop: [{ hooks: customCommands.map((c) => ({ type: 'command', command: c })) }] } };
fs.writeFileSync(path.join(home, 'hooks.json'), JSON.stringify(seeded, null, 2) + '\n', 'utf8');
reconcileCodexHooksJsonEvent(home, 'Stop', { managedCommand: null });
const after = JSON.parse(fs.readFileSync(path.join(home, 'hooks.json'), 'utf8'));
const survivingCommands = ((after.hooks && after.hooks.Stop) || [])
.flatMap((entry) => (entry.hooks || []).map((h) => h.command));
for (const c of customCommands) {
assert.ok(survivingCommands.includes(c), `non-managed command "${c}" must survive removal`);
}
} finally {
cleanup(home);
}
},
),
{ numRuns: 25 },
);
});
});