11262 lines
505 KiB
JavaScript
11262 lines
505 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 } = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||
// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel
|
||
// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam
|
||
// decision"). Neither export exists on model-catalog.cjs yet; requiring the
|
||
// module does not throw (it just has no such keys today), but calling
|
||
// isAnthropicFlavoredModel does — see the new describe block below.
|
||
const modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs');
|
||
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||
|
||
// #2153 follow-up: ensure hooks/dist/ exists before any install integration
|
||
// test runs. The Codex install path copies hook files from hooks/dist/, which
|
||
// is gitignored and only populated by `npm run build:hooks`. When this file is
|
||
// run in isolation (`node --test tests/codex-config.test.cjs`) 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,
|
||
convertClaudeAgentToCodexAgent,
|
||
convertClaudeCommandToCodexSkill,
|
||
generateCodexAgentToml,
|
||
_resetCodexWarningDedupeForTests,
|
||
cleanupCodexSkillMetadataSidecars,
|
||
generateCodexConfigBlock,
|
||
stripGsdFromCodexConfig,
|
||
migrateCodexHooksMapFormat,
|
||
mergeCodexConfig,
|
||
install,
|
||
GSD_CODEX_MARKER,
|
||
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,
|
||
parseTomlToObject,
|
||
validateCodexConfigSchema,
|
||
uninstall,
|
||
CODEX_EXTENDED_HOOK_EVENTS,
|
||
} = require('../bin/install.js');
|
||
|
||
const { resolveNodeRunner } = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||
const { 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,
|
||
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 } = 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 ─────────────────────────────────────────────────
|
||
|
||
describe('getCodexSkillAdapterHeader', () => {
|
||
test('contains all three sections', () => {
|
||
const result = getCodexSkillAdapterHeader('gsd-execute-phase');
|
||
assert.ok(result.includes('<codex_skill_adapter>'), 'has opening tag');
|
||
assert.ok(result.includes('</codex_skill_adapter>'), 'has closing tag');
|
||
assert.ok(result.includes('## A. Skill Invocation'), 'has section A');
|
||
assert.ok(result.includes('## B. AskUserQuestion'), 'has section B');
|
||
assert.ok(result.includes('## C. Task() → spawn_agent'), 'has section C');
|
||
});
|
||
|
||
test('includes correct invocation syntax', () => {
|
||
const result = getCodexSkillAdapterHeader('gsd-plan-phase');
|
||
assert.ok(result.includes('`$gsd-plan-phase`'), 'has $skillName invocation');
|
||
assert.ok(result.includes('{{GSD_ARGS}}'), 'has GSD_ARGS variable');
|
||
});
|
||
|
||
test('section B maps AskUserQuestion parameters', () => {
|
||
const result = getCodexSkillAdapterHeader('gsd-discuss-phase');
|
||
assert.ok(result.includes('request_user_input'), 'maps to request_user_input');
|
||
assert.ok(result.includes('header'), 'maps header parameter');
|
||
assert.ok(result.includes('question'), 'maps question parameter');
|
||
assert.ok(result.includes('label'), 'maps options label');
|
||
assert.ok(result.includes('description'), 'maps options description');
|
||
assert.ok(result.includes('multiSelect'), 'documents multiSelect workaround');
|
||
assert.ok(result.includes('Execute mode'), 'documents Execute mode fallback');
|
||
});
|
||
|
||
test('section C maps Task to spawn_agent', () => {
|
||
const result = getCodexSkillAdapterHeader('gsd-execute-phase');
|
||
assert.ok(result.includes('spawn_agent'), 'maps to spawn_agent');
|
||
assert.ok(result.includes('agent_type'), 'maps subagent_type to agent_type');
|
||
// #4270: resolve-model exposes the portable field as `effort`; the Codex
|
||
// adapter must fetch it and translate it to spawn_agent.reasoning_effort.
|
||
assert.match(
|
||
result,
|
||
/query resolve-model <subagent_type> --pick effort/,
|
||
'retrieves the unified effort for the dispatched role',
|
||
);
|
||
assert.match(
|
||
result,
|
||
/unified `effort` field maps to the Codex spawn argument\s+`reasoning_effort`/,
|
||
'documents reasoning_effort transport',
|
||
);
|
||
assert.ok(result.includes('do not invent one-off effort literals'), 'keeps effort policy centralized');
|
||
assert.ok(result.includes('fork_context'), 'documents fork_context default');
|
||
// #3004: collaboration tool vocabulary must match Codex's actual schema.
|
||
assert.ok(result.includes('wait_agent'), 'documents the real collaboration wait tool (wait_agent, not wait(ids))');
|
||
assert.ok(!result.includes('wait(ids)'), 'must NOT contain the obsolete wait(ids) spelling');
|
||
assert.ok(result.includes('functions.wait'), 'disambiguates from the unrelated exec-cell functions.wait tool');
|
||
assert.ok(result.includes('task_name'), 'documents the required task_name field');
|
||
assert.ok(result.includes('fork_turns'), 'documents the fork_turns parameter');
|
||
assert.ok(result.includes('close_agent'), 'documents close_agent cleanup');
|
||
assert.ok(result.includes('tool_search'), 'gates close_agent on tool visibility (schema detection)');
|
||
assert.ok(result.includes('CHECKPOINT'), 'documents result markers');
|
||
});
|
||
});
|
||
|
||
// ─── convertClaudeAgentToCodexAgent ─────────────────────────────────────────────
|
||
|
||
describe('convertClaudeAgentToCodexAgent', () => {
|
||
test('adds codex_agent_role header and cleans frontmatter', () => {
|
||
const input = `---
|
||
name: gsd-executor
|
||
description: Executes GSD plans with atomic commits
|
||
tools: Read, Write, Edit, Bash, Grep, Glob
|
||
color: yellow
|
||
---
|
||
|
||
<role>
|
||
You are a GSD plan executor.
|
||
</role>`;
|
||
|
||
const result = convertClaudeAgentToCodexAgent(input);
|
||
|
||
// Frontmatter rebuilt with only name and description
|
||
assert.ok(result.startsWith('---\n'), 'starts with frontmatter');
|
||
assert.ok(result.includes('"gsd-executor"'), 'has quoted name');
|
||
assert.ok(result.includes('"Executes GSD plans with atomic commits"'), 'has quoted description');
|
||
assert.ok(!result.includes('color: yellow'), 'drops color field');
|
||
// Tools should be in <codex_agent_role> but NOT in frontmatter
|
||
const fmEnd = result.indexOf('---', 4);
|
||
const frontmatterSection = result.substring(0, fmEnd);
|
||
assert.ok(!frontmatterSection.includes('tools:'), 'drops tools from frontmatter');
|
||
|
||
// Has codex_agent_role block
|
||
assert.ok(result.includes('<codex_agent_role>'), 'has role header');
|
||
assert.ok(result.includes('role: gsd-executor'), 'role matches agent name');
|
||
assert.ok(result.includes('tools: Read, Write, Edit, Bash, Grep, Glob'), 'tools in role block');
|
||
assert.ok(result.includes('purpose: Executes GSD plans with atomic commits'), 'purpose from description');
|
||
assert.ok(result.includes('</codex_agent_role>'), 'has closing tag');
|
||
|
||
// Body preserved
|
||
assert.ok(result.includes('<role>'), 'body content preserved');
|
||
});
|
||
|
||
test('converts slash commands in body', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test agent
|
||
tools: Read
|
||
---
|
||
|
||
Run /gsd:execute-phase to proceed.`;
|
||
|
||
const result = convertClaudeAgentToCodexAgent(input);
|
||
assert.ok(result.includes('$gsd-execute-phase'), 'converts slash commands');
|
||
assert.ok(!result.includes('/gsd:execute-phase'), 'original slash command removed');
|
||
});
|
||
|
||
test('handles content without frontmatter', () => {
|
||
const input = 'Just some content without frontmatter.';
|
||
const result = convertClaudeAgentToCodexAgent(input);
|
||
assert.strictEqual(result, input, 'returns input unchanged');
|
||
});
|
||
|
||
test('replaces .claude paths with .codex paths (#1430)', () => {
|
||
const input = `---
|
||
name: gsd-debugger
|
||
description: Debugs issues
|
||
tools: Read, Bash
|
||
---
|
||
|
||
INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state load)
|
||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" commit "docs: resolve"`;
|
||
|
||
const result = convertClaudeAgentToCodexAgent(input);
|
||
assert.ok(result.includes('$HOME/.codex/gsd-core/bin/gsd-tools.cjs'), 'replaces $HOME/.claude/ with $HOME/.codex/');
|
||
assert.ok(!result.includes('$HOME/.claude/'), 'no .claude paths remain');
|
||
});
|
||
|
||
test('rewrites bare gsd-tools invocations to the Codex shim path', () => {
|
||
const input = `---
|
||
name: gsd-planner
|
||
description: Plans phases
|
||
tools: Read, Bash
|
||
---
|
||
|
||
INIT=$(gsd-tools query init.plan-phase "\${PHASE}")
|
||
gsd-tools query state.load 2>/dev/null
|
||
if command -v gsd-tools >/dev/null 2>&1; then echo "path fallback"; fi
|
||
Use \`gsd-tools query history-digest\` for history.`;
|
||
|
||
const result = convertClaudeAgentToCodexAgent(input);
|
||
assert.ok(
|
||
result.includes('INIT=$(node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query init.plan-phase'),
|
||
'rewrites command substitution',
|
||
);
|
||
assert.ok(
|
||
result.includes('node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query state.load'),
|
||
'rewrites line-start command',
|
||
);
|
||
assert.ok(
|
||
result.includes('`node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query history-digest`'),
|
||
'rewrites inline command example',
|
||
);
|
||
assert.ok(result.includes('command -v gsd-tools'), 'keeps PATH resolver probe intact');
|
||
assertNoCodexBareGsdToolsInvocation(result, 'converted Codex agent');
|
||
});
|
||
});
|
||
|
||
// ─── Codex command prefix conversion ────────────────────────────────────────────
|
||
|
||
describe('Codex hyphen-style command prefix conversion', () => {
|
||
test('converts /gsd-command in workflow output to $gsd-command', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test
|
||
tools: Read
|
||
---
|
||
|
||
/gsd-discuss-phase 1 — gather context
|
||
/gsd-plan-phase 2 — create plan
|
||
/gsd-execute-phase 3 — run it`;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||
assert.ok(result.includes('$gsd-discuss-phase'), 'converts /gsd-discuss-phase');
|
||
assert.ok(result.includes('$gsd-plan-phase'), 'converts /gsd-plan-phase');
|
||
assert.ok(result.includes('$gsd-execute-phase'), 'converts /gsd-execute-phase');
|
||
assert.ok(!result.includes('/gsd-discuss-phase'), 'no /gsd-discuss-phase remains');
|
||
});
|
||
|
||
test('converts backtick-wrapped /gsd- commands', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test
|
||
tools: Read
|
||
---
|
||
|
||
Run \`/gsd-plan-phase 1\` to plan.`;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||
assert.ok(result.includes('$gsd-plan-phase'), 'converts backtick-wrapped command');
|
||
});
|
||
|
||
test('does not convert /gsd- in file paths', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test
|
||
tools: Read
|
||
---
|
||
|
||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init`;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||
assert.ok(result.includes('gsd-tools.cjs'), 'gsd-tools.cjs preserved in path');
|
||
assert.ok(!result.includes('$gsd-tools'), 'no $gsd-tools in file path');
|
||
});
|
||
|
||
test('rewrites bare gsd-tools commands in generated Codex skills', () => {
|
||
const input = `---
|
||
name: gsd:quick
|
||
description: Quick task
|
||
---
|
||
|
||
\`\`\`bash
|
||
gsd-tools query frontmatter.get .planning/quick/example/SUMMARY.md status
|
||
INIT=$(gsd-tools query init.quick)
|
||
if command -v gsd-tools >/dev/null 2>&1; then echo ok; fi
|
||
\`\`\`
|
||
|
||
Status fields read via \`gsd-tools query frontmatter.get\`.`;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-quick');
|
||
assert.ok(
|
||
result.includes('node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query frontmatter.get'),
|
||
'rewrites line-start command in a shell block',
|
||
);
|
||
assert.ok(
|
||
result.includes('INIT=$(node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query init.quick)'),
|
||
'rewrites command substitution in a shell block',
|
||
);
|
||
assert.ok(
|
||
result.includes('`node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs" query frontmatter.get`'),
|
||
'rewrites inline command example',
|
||
);
|
||
assert.ok(result.includes('command -v gsd-tools'), 'keeps resolver probe intact');
|
||
assertNoCodexBareGsdToolsInvocation(result, 'converted Codex skill');
|
||
});
|
||
|
||
test('removes /clear then: for Codex', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test
|
||
tools: Read
|
||
---
|
||
|
||
\`/clear\` then:
|
||
|
||
\`$gsd-plan-phase 1\``;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||
assert.ok(!result.includes('/clear'), 'no /clear remains');
|
||
assert.ok(result.includes('$gsd-plan-phase'), 'command preserved after /clear removal');
|
||
});
|
||
|
||
test('removes bare /clear then: for Codex', () => {
|
||
const input = `---
|
||
name: gsd-test
|
||
description: Test
|
||
tools: Read
|
||
---
|
||
|
||
/clear then:
|
||
/gsd-execute-phase 2`;
|
||
|
||
const result = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||
assert.ok(!result.includes('/clear'), 'no /clear remains');
|
||
assert.ok(result.includes('$gsd-execute-phase'), 'command converted');
|
||
});
|
||
});
|
||
|
||
// ─── generateCodexAgentToml ─────────────────────────────────────────────────────
|
||
|
||
describe('generateCodexAgentToml', () => {
|
||
const sampleAgent = `---
|
||
name: gsd-executor
|
||
description: Executes plans
|
||
tools: Read, Write, Edit
|
||
color: yellow
|
||
---
|
||
|
||
<role>You are an executor.</role>`;
|
||
|
||
test('sets workspace-write for executor', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'has workspace-write');
|
||
});
|
||
|
||
test('sets read-only for plan-checker', () => {
|
||
const checker = `---
|
||
name: gsd-plan-checker
|
||
description: Checks plans
|
||
tools: Read, Grep, Glob
|
||
---
|
||
|
||
<role>You check plans.</role>`;
|
||
const result = generateCodexAgentToml('gsd-plan-checker', checker);
|
||
assert.ok(result.includes('sandbox_mode = "read-only"'), 'has read-only');
|
||
});
|
||
|
||
test('includes developer_instructions from body', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||
assert.ok(result.includes("developer_instructions = '''"), 'has literal triple-quoted instructions');
|
||
assert.ok(result.includes('<role>You are an executor.</role>'), 'body content in instructions');
|
||
assert.ok(result.includes("'''"), 'has closing literal triple quotes');
|
||
});
|
||
|
||
test('includes required name and description fields', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||
assert.ok(result.includes('name = "gsd-executor"'), 'has name');
|
||
assert.ok(result.includes('description = "Executes plans"'), 'has description');
|
||
});
|
||
|
||
test('falls back to generated description when frontmatter is missing fields', () => {
|
||
const minimalAgent = `<role>You are an unknown agent.</role>`;
|
||
const result = generateCodexAgentToml('gsd-unknown', minimalAgent);
|
||
assert.ok(result.includes('name = "gsd-unknown"'), 'falls back to agent name');
|
||
assert.ok(result.includes('description = "GSD agent gsd-unknown"'), 'falls back to synthetic description');
|
||
});
|
||
|
||
// #3897 CAUSE B fix: this used to assert the deleted name-based fallback
|
||
// (an unknown agent NAME defaulted to read-only regardless of its tool
|
||
// contract). Under derivation, identity no longer determines the sandbox —
|
||
// the tool contract does (S6: "a new writing role gets the contract, not
|
||
// the pin"). Split into the two rows the old single assertion conflated:
|
||
// absence of a `tools:` grant (N8) vs. an unknown role that legitimately
|
||
// declares a writing tool (S6).
|
||
test('unknown agent with no tools: frontmatter derives read-only (N8: absence is not a grant)', () => {
|
||
const noToolsAgent = `---
|
||
name: gsd-unknown
|
||
description: An unknown agent with no declared tools
|
||
---
|
||
|
||
<role>You are an unknown agent.</role>`;
|
||
const result = generateCodexAgentToml('gsd-unknown', noToolsAgent);
|
||
assert.ok(result.includes('sandbox_mode = "read-only"'), 'no tools: frontmatter -> read-only');
|
||
});
|
||
|
||
test('unknown agent declaring Write/Edit derives workspace-write (S6: the tool contract, not the pin, decides)', () => {
|
||
const result = generateCodexAgentToml('gsd-unknown', sampleAgent);
|
||
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'declares Write/Edit -> workspace-write');
|
||
});
|
||
|
||
// ─── #2256: model_overrides support ───────────────────────────────────────
|
||
|
||
test('emits model field when modelOverrides contains an entry for the agent (#2256)', () => {
|
||
const overrides = { 'gsd-executor': 'gpt-5.3-codex' };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, overrides);
|
||
assert.ok(result.includes('model = "gpt-5.3-codex"'), 'model field must be present in TOML');
|
||
});
|
||
|
||
test('does not emit model field when modelOverrides is null (#2256)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null);
|
||
assert.ok(!result.includes('model ='), 'model field must be absent when no override');
|
||
});
|
||
|
||
test('does not emit reasoning effort when Codex model is inherited (#838)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null);
|
||
assert.ok(!result.includes('model ='), 'model field must be absent when Codex should inherit');
|
||
assert.ok(
|
||
!result.includes('model_reasoning_effort ='),
|
||
'reasoning effort must stay absent when the model is inherited'
|
||
);
|
||
});
|
||
|
||
test('emits reasoning effort when model override pins Codex model (#838)', () => {
|
||
const overrides = { 'gsd-executor': 'gpt-5.3-codex' };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, overrides);
|
||
assert.ok(result.includes('model = "gpt-5.3-codex"'), 'model override must pin model');
|
||
assert.ok(
|
||
result.includes('model_reasoning_effort ='),
|
||
'reasoning effort is safe to emit when GSD also pins model'
|
||
);
|
||
});
|
||
|
||
test('omits model and reasoning effort when only the runtime resolver would have pinned one (#838, #3241)', () => {
|
||
// #3241 flips this test: the runtime-resolver auto-embed block (D1) was
|
||
// removed, so a resolver alone (no explicit model_overrides) no longer
|
||
// pins a model at install time, and #838's model/effort coupling means
|
||
// neither line survives.
|
||
const runtimeResolver = { resolve: () => ({ model: 'gpt-5.5' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!result.includes('model = "gpt-5.5"'), 'runtime resolver alone must not pin model (#3241)');
|
||
assert.ok(
|
||
!result.includes('model_reasoning_effort ='),
|
||
'reasoning effort must not survive an omitted resolver model (#838 coupling)'
|
||
);
|
||
});
|
||
|
||
test('does not emit model field when modelOverrides has no entry for this agent (#2256)', () => {
|
||
const overrides = { 'gsd-planner': 'gpt-5.4' };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, overrides);
|
||
assert.ok(!result.includes('model ='), 'model field must be absent for agents not in overrides');
|
||
});
|
||
|
||
test('model field appears before developer_instructions (#2256)', () => {
|
||
const overrides = { 'gsd-executor': 'gpt-5.3-codex' };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, overrides);
|
||
const modelIdx = result.indexOf('model = "gpt-5.3-codex"');
|
||
const instrIdx = result.indexOf("developer_instructions = '''");
|
||
assert.ok(modelIdx !== -1, 'model field present');
|
||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||
assert.ok(modelIdx < instrIdx, 'model field must appear before developer_instructions');
|
||
});
|
||
|
||
// ─── #2310: never leak an Anthropic-flavored model into the Codex .toml ─────
|
||
|
||
test('omits a bare GSD tier alias in model_overrides (Codex passive/session-only) (#2310)', () => {
|
||
// ADR-1239: Codex is a passive/session-only model host. A tier alias cannot be
|
||
// honored per-agent, so it is dropped and the agent inherits the session model (no 400).
|
||
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': alias });
|
||
assert.ok(!/^model = /m.test(result), `alias "${alias}" must be omitted (no model pinned)`);
|
||
}
|
||
});
|
||
|
||
test('never emits a bare Anthropic tier alias as the Codex model (#2310)', () => {
|
||
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': alias });
|
||
assert.ok(!/^model = "(opus|sonnet|haiku|fable)"$/m.test(result), `must not emit model = "${alias}"`);
|
||
}
|
||
});
|
||
|
||
test('drops a claude-* model_overrides id instead of leaking it into the Codex .toml (#2310)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': 'claude-sonnet-5' });
|
||
assert.ok(!result.includes('claude-'), 'a claude-* id must never appear as the Codex model');
|
||
// No runtime resolver → nothing to fall through to → no model line at all.
|
||
assert.ok(!result.includes('model ='), 'unmappable Anthropic override falls through to Codex default (no model pinned)');
|
||
});
|
||
|
||
test('a dropped claude-* override no longer falls through to the runtime resolver (#2310, #3241)', () => {
|
||
// #3241 (D1) removed the runtime-resolver fallback embed entirely, so a
|
||
// dropped alias/claude-* override now has nothing left to fall through to
|
||
// — it is simply omitted, same as the claude id never leaking.
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-terra' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': 'claude-opus-4-8' }, runtimeResolver);
|
||
assert.ok(!result.includes('model = "gpt-5.6-terra"'), 'resolver fallback no longer fires (#3241 D1)');
|
||
assert.ok(!result.includes('claude-'), 'claude id must not leak even with a resolver present');
|
||
});
|
||
|
||
test('final gate blocks an Anthropic model from the runtime-resolver path too (#2310)', () => {
|
||
// Simulate a defaults.json runtime that does not match the codex install target:
|
||
// the resolver hands back a Claude id, which must still never reach the Codex .toml.
|
||
const runtimeResolver = { runtime: 'claude', resolve: () => ({ model: 'claude-sonnet-5' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!result.includes('claude-'), 'runtime-resolver Claude id must be gated out of the Codex .toml');
|
||
assert.ok(!result.includes('model ='), 'no valid Codex model available → none pinned');
|
||
});
|
||
|
||
test('still emits a real Codex/OpenAI model_overrides id verbatim (#2310 preserves #2256)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': 'gpt-5.6-sol' });
|
||
assert.ok(result.includes('model = "gpt-5.6-sol"'), 'a real gpt-* override must still pass through unchanged');
|
||
});
|
||
|
||
test('gates a provider-namespaced anthropic/claude-* model from the runtime-resolver path (#2310 review)', () => {
|
||
// Catalog assigns anthropic/claude-* to opencode/hermes/kilo. A mixed-runtime config
|
||
// (runtime: opencode) + Codex install resolves those; they must NOT reach the .toml.
|
||
const runtimeResolver = { runtime: 'opencode', resolve: () => ({ model: 'anthropic/claude-opus-4-8' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!/claude/i.test(result.split('\n').find((l) => /^model = /.test(l)) || ''), 'no claude-bearing model may be emitted');
|
||
assert.ok(!/^model = /m.test(result), 'anthropic/claude-* is gated out → no model pinned');
|
||
});
|
||
|
||
test('omits a provider-namespaced anthropic/claude-* model_overrides pin (#2310 review)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': 'anthropic/claude-sonnet-5' });
|
||
assert.ok(!result.includes('claude'), 'anthropic/claude-* must be omitted, never emitted');
|
||
assert.ok(!/^model = /m.test(result), 'no model pinned');
|
||
});
|
||
|
||
test('every canonical Claude tier alias is omitted (single-source guard, #2310 review)', () => {
|
||
// Iterates the CANONICAL set so a future alias is covered automatically (no divergence).
|
||
for (const alias of CLAUDE_AGENT_ALIASES) {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': alias });
|
||
assert.ok(!/^model = /m.test(result), `canonical alias "${alias}" must be omitted`);
|
||
}
|
||
});
|
||
|
||
test('drops the fable alias (Claude Agent alias with no Codex mapping) (#2310)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': 'fable' });
|
||
assert.ok(!result.includes('model = "fable"'), 'fable must never be emitted as the Codex model');
|
||
assert.ok(!result.includes('model ='), 'fable has no Codex mapping → dropped, no model pinned');
|
||
});
|
||
|
||
test('property: no model_overrides value ever yields an Anthropic-flavored Codex model (#2310)', () => {
|
||
const anthropicish = fc.oneof(
|
||
fc.constantFrom('opus', 'sonnet', 'haiku', 'fable'),
|
||
fc.string().map((s) => `claude-${s}`),
|
||
fc.string().map((s) => `anthropic/claude-${s}`),
|
||
fc.string().map((s) => `us.anthropic.claude-${s}`),
|
||
fc.string(),
|
||
);
|
||
fc.assert(fc.property(anthropicish, (v) => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': v });
|
||
const m = result.split('\n').find((l) => /^model = /.test(l));
|
||
if (!m) return true; // no model pinned is always safe
|
||
return !/claude/i.test(m) && !/^model = "(opus|sonnet|haiku|fable)"$/.test(m);
|
||
}), { numRuns: 400 });
|
||
});
|
||
|
||
// ─── #3241: omit the Codex per-agent model by default (resolver-only path) ────
|
||
// Phase 1 removes the runtime-resolver auto-embed. These tests drive the
|
||
// shipping default shape — runtime set + model_profile:"balanced" (mocked
|
||
// here as a resolver object, matching the existing #2517/#838 tests above,
|
||
// e.g. L514-522) — with NO model_overrides, and assert the model line (and
|
||
// its coupled model_reasoning_effort, #838) are omitted.
|
||
|
||
test('omits model and model_reasoning_effort when only the runtime resolver would have supplied one (#3241)', () => {
|
||
// RED (pre-fix): today this resolver-only path still embeds the tier
|
||
// model (see L514-522's "runtime resolver pins Codex model" test, which
|
||
// asserts the opposite of this on purpose and is left untouched per the
|
||
// Phase 1 rollout plan). Both assertions below fail against the current
|
||
// tree: `model = "gpt-5.6-sol"` and `model_reasoning_effort = "high"`
|
||
// are both present in `result` today.
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!/^model = /m.test(result),
|
||
'a resolver-only tier model must not be embedded by default (#3241)');
|
||
assert.ok(!result.includes('model_reasoning_effort ='),
|
||
'reasoning effort must not survive an omitted resolver model (#838 coupling)');
|
||
});
|
||
|
||
test('resolver is null (inherit profile or no runtime configured) emits no model and no warning (#3241)', (t) => {
|
||
// Regression guard — PASSES today already: readGsdRuntimeProfileResolver
|
||
// already returns null for both "no runtime" and model_profile:"inherit"
|
||
// (bin/install.js:1632, :1635), and generateCodexAgentToml already omits
|
||
// the model when runtimeResolver is null. Nothing in Phase 1 touches this
|
||
// branch; this test exists to prove it keeps holding after the fix lands.
|
||
const origWrite = process.stderr.write;
|
||
const stderrChunks = [];
|
||
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
|
||
t.after(() => { process.stderr.write = origWrite; });
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, null);
|
||
assert.ok(!/^model = /m.test(result), 'no model when the resolver is null');
|
||
assert.strictEqual(stderrChunks.join(''), '', 'inherit/no-runtime users must never be warned — nothing was lost');
|
||
});
|
||
|
||
test('resolver present but resolve() yields nothing emits no model and no warning (#3241)', (t) => {
|
||
// Regression guard — PASSES today already: entry?.model is undefined when
|
||
// resolve() returns null, so pinnedModel stays null and no warning branch
|
||
// is reachable in current code. Nothing was lost, so nothing should warn,
|
||
// before or after the fix (negative-space row in 40-design.md).
|
||
const origWrite = process.stderr.write;
|
||
const stderrChunks = [];
|
||
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
|
||
t.after(() => { process.stderr.write = origWrite; });
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => null };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!/^model = /m.test(result), 'no model when resolve() yields nothing');
|
||
assert.strictEqual(stderrChunks.join(''), '', 'a resolver that would not have pinned anything must never warn');
|
||
});
|
||
|
||
test('empty-string and whitespace-only model_overrides are not pins and not warnings (#3241)', (t) => {
|
||
// '' — regression guard, PASSES today: '' is falsy, so the pin branch is
|
||
// never entered at all (no pin, no warning either old or new).
|
||
//
|
||
// ' ' (whitespace-only) — RED (pre-fix, live defect, fixed in this
|
||
// phase per maintainer direction): a whitespace-only override is a
|
||
// truthy JS string, is not Anthropic-flavored per `_isAnthropicFlavoredModel`,
|
||
// and is CURRENTLY pinned verbatim (`model = " "`) with no guard —
|
||
// the same class of bug the #2310 guard exists to stop (a non-model
|
||
// value reaching the .toml and 400-ing the Codex agent). Must be
|
||
// silently dropped, matching how '' already behaves — NOT routed through
|
||
// `_warnCodexModelOverrideDropped` (that warning's "is not a valid Codex
|
||
// model (Anthropic alias/id)" text would misdescribe a blank field), so
|
||
// no warning of any kind is expected for it either.
|
||
const origWrite = process.stderr.write;
|
||
const stderrChunks = [];
|
||
process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
|
||
t.after(() => { process.stderr.write = origWrite; });
|
||
const emptyResult = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': '' });
|
||
const whitespaceResult = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': ' ' });
|
||
assert.ok(!/^model = /m.test(emptyResult), 'empty-string override must not be pinned');
|
||
assert.ok(!/^model = /m.test(whitespaceResult), 'whitespace-only override must not be pinned (#3241 fix)');
|
||
assert.strictEqual(stderrChunks.join(''), '', 'empty-string/whitespace overrides must never warn');
|
||
});
|
||
|
||
test('non-string model_overrides values are ignored without throwing (#3241)', () => {
|
||
// Regression guard — PASSES today already: none of these ever reach the
|
||
// string-pin branch (`typeof rawModelOverride === 'string'` gates it), so
|
||
// no value here is ever emitted as `model =`, and none of them throw.
|
||
// Some truthy non-string values (42, {}, true, []) DO hit the existing
|
||
// `_warnCodexModelOverrideDropped` warn branch today — that is pre-2310
|
||
// behavior this phase does not touch, so no assertion is made on warning
|
||
// presence/absence here, only "no pin" and "no crash" per the matrix.
|
||
const hostileValues = [42, {}, null, true, [], 0, NaN];
|
||
for (const value of hostileValues) {
|
||
assert.doesNotThrow(() => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, { 'gsd-executor': value });
|
||
assert.ok(!/^model = /m.test(result), `non-string override ${JSON.stringify(value)} must not be pinned`);
|
||
}, `non-string override ${JSON.stringify(value)} must not throw`);
|
||
}
|
||
});
|
||
|
||
// 50-test-matrix.md row 15 (oversized warning value truncated at 64 chars)
|
||
// is deliberately NOT covered here. Maintainer-confirmed pinned wording
|
||
// (see the describe block below) interpolates no user-controlled value —
|
||
// no agent name, no model string — so there is nothing in the message that
|
||
// could ever exhibit truncation. A test asserting truncation against a
|
||
// message with no interpolated value would be vacuous by construction.
|
||
|
||
test('light-tier service_tier/model_verbosity survive independent of whether a model is pinned (#3241, #774 decoupling guard)', () => {
|
||
// Regression guard — PASSES today already: the light-tier emission block
|
||
// (bin/install.js ~L4198-4203) reads AGENT_DEFAULT_TIERS unconditionally
|
||
// and never inspects pinnedModel/hasPinnedModel. This test exists to
|
||
// catch a FUTURE implementation that wrongly couples these fields to
|
||
// hasPinnedModel while implementing #3241 — if that coupling is ever
|
||
// introduced, this is the test that turns red. It is not expected to be
|
||
// red before the #3241 fix lands, and per 50-test-matrix.md's own
|
||
// "Red-before-green" note this is the row most likely to be accidentally
|
||
// vacuous — flagged explicitly here rather than mis-classified.
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
|
||
const lightAgent = `---
|
||
name: gsd-plan-checker
|
||
description: Checks plans quickly
|
||
tools: Read, Grep
|
||
---
|
||
|
||
<role>You check plans.</role>`;
|
||
const lightResult = generateCodexAgentToml('gsd-plan-checker', lightAgent, null, runtimeResolver);
|
||
assert.ok(lightResult.includes('service_tier = "flex"'), 'service_tier must not be coupled to whether a model is pinned');
|
||
assert.ok(lightResult.includes('model_verbosity = "low"'), 'model_verbosity must not be coupled to whether a model is pinned');
|
||
|
||
// Other direction: a non-light agent with no model at all must still gain
|
||
// neither field (duplicates the existing #774 coverage at L647-652
|
||
// intentionally — 50-test-matrix.md row 18 folds this into row 17 as the
|
||
// same independence guard, viewed from the opposite direction).
|
||
const standardResult = generateCodexAgentToml('gsd-executor', sampleAgent, null, null);
|
||
assert.ok(!standardResult.includes('service_tier'), 'standard-tier agent must not gain service_tier just because no model is pinned');
|
||
assert.ok(!standardResult.includes('model_verbosity'), 'standard-tier agent must not gain model_verbosity just because no model is pinned');
|
||
});
|
||
|
||
// ─── #3241 review: gate the deprecation notice on "would have been EMBEDDED",
|
||
// not "would have been returned" ────────────────────────────────────────────
|
||
// The resolver-would-have-supplied-a-model check above (L652-665) doesn't
|
||
// inspect stderr, so it couldn't catch this: the notice must not fire when
|
||
// the would-be resolver model would ALSO have been rejected by the #2310
|
||
// Anthropic-flavored gate (L4192) pre-Phase-1 — that user never had the pin
|
||
// in the first place, so telling them to set model_overrides is false. The
|
||
// notice's one-time dedupe is a module-level boolean shared across every
|
||
// test in this file (an earlier test in this describe block, e.g.
|
||
// L652-665, may have already latched it), so each test here resets it via
|
||
// the documented test seam (_resetCodexWarningDedupeForTests) instead of
|
||
// busting require.cache — a cache bust would create a second module
|
||
// instance and break every other test in this file that assumes a single
|
||
// shared instance.
|
||
|
||
function captureStderr(t) {
|
||
const origWrite = process.stderr.write;
|
||
const chunks = [];
|
||
process.stderr.write = (chunk) => { chunks.push(String(chunk)); return true; };
|
||
t.after(() => { process.stderr.write = origWrite; });
|
||
return () => chunks.join('').split(/\r?\n/).filter((l) => l.length > 0);
|
||
}
|
||
|
||
test('no deprecation notice when the resolver would only have produced an Anthropic-flavored model (#3241 review — defect fix)', (t) => {
|
||
_resetCodexWarningDedupeForTests();
|
||
const getLines = captureStderr(t);
|
||
// Mixed-runtime config (runtime: opencode) resolving against a Codex
|
||
// install target — the #2310 gate (bin/install.js:4192) rejects this
|
||
// model BEFORE Phase 1 too, so this user never had the pin. Bare alias
|
||
// form covered too, since both routes hit the same gate.
|
||
for (const model of ['anthropic/claude-opus-4-8', 'sonnet']) {
|
||
const runtimeResolver = { runtime: 'opencode', resolve: () => ({ model }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!/^model = /m.test(result), `no model pinned for would-be resolver model "${model}"`);
|
||
}
|
||
const noticeLines = getLines().filter((l) => l.startsWith('gsd: notice — '));
|
||
assert.strictEqual(noticeLines.length, 0,
|
||
'no notice: the resolver model would never have survived the #2310 gate pre-Phase-1 either, so nothing was lost');
|
||
});
|
||
|
||
test('deprecation notice still fires when the resolver would have produced a legal Codex model (#3241 review)', (t) => {
|
||
_resetCodexWarningDedupeForTests();
|
||
const getLines = captureStderr(t);
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, runtimeResolver);
|
||
assert.ok(!/^model = /m.test(result), 'no model pinned by default (#3241 D1)');
|
||
const noticeLines = getLines().filter((l) => l.startsWith('gsd: notice — '));
|
||
assert.strictEqual(noticeLines.length, 1,
|
||
'exactly one notice: a legal gpt-5.6-sol model would have been embedded pre-Phase-1, and now is not — the fix must not over-correct into silence');
|
||
});
|
||
|
||
test('both the override-dropped warning and the resolver-omitted notice fire for an Anthropic override plus a legal resolver model (#3241 review — intentional, do NOT collapse to one message)', (t) => {
|
||
// NOT a defect. Two distinct true facts, two distinct prefixes:
|
||
// - model_overrides:"sonnet" is Anthropic-flavored → dropped pre-Phase-1
|
||
// too (#2310 gate on the override path) → `gsd: warning — ` fires.
|
||
// - With the override dropped, execution falls through to the runtime
|
||
// resolver, which WOULD have supplied "gpt-5.6-sol" (a legal Codex
|
||
// model) and that pin WOULD have been embedded pre-Phase-1 → this user
|
||
// genuinely lost a pin → `gsd: notice — ` fires too.
|
||
// A future reader must not "fix" this down to one message.
|
||
_resetCodexWarningDedupeForTests();
|
||
const getLines = captureStderr(t);
|
||
const runtimeResolver = { runtime: 'codex', resolve: () => ({ model: 'gpt-5.6-sol' }) };
|
||
const result = generateCodexAgentToml(
|
||
'gsd-executor', sampleAgent, { 'gsd-executor': 'sonnet' }, runtimeResolver,
|
||
);
|
||
assert.ok(!/^model = /m.test(result), 'no model pinned (Anthropic override dropped, resolver model not auto-embedded)');
|
||
const lines = getLines();
|
||
const warningLines = lines.filter((l) => l.startsWith('gsd: warning — '));
|
||
const noticeLines = lines.filter((l) => l.startsWith('gsd: notice — '));
|
||
assert.strictEqual(warningLines.length, 1, 'exactly one warning: the Anthropic override was dropped');
|
||
assert.strictEqual(noticeLines.length, 1, 'exactly one notice: the legal resolver model would have been embedded and now is not');
|
||
});
|
||
|
||
// ─── #774: service_tier / model_verbosity for light-tier agents ───────────────
|
||
|
||
test('emits service_tier="flex" and model_verbosity="low" for light-tier agents (#774)', () => {
|
||
// gsd-plan-checker has routingTier:"light" in model-catalog.json
|
||
const lightAgent = `---
|
||
name: gsd-plan-checker
|
||
description: Checks plans quickly
|
||
tools: Read, Grep
|
||
---
|
||
|
||
<role>You check plans.</role>`;
|
||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||
assert.ok(result.includes('service_tier = "flex"'), 'light-tier agent must have service_tier = "flex"');
|
||
assert.ok(result.includes('model_verbosity = "low"'), 'light-tier agent must have model_verbosity = "low"');
|
||
});
|
||
|
||
test('does not emit service_tier or model_verbosity for standard-tier agents (#774)', () => {
|
||
// gsd-executor has routingTier:"standard" in model-catalog.json
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||
assert.ok(!result.includes('service_tier'), 'standard-tier agent must not have service_tier');
|
||
assert.ok(!result.includes('model_verbosity'), 'standard-tier agent must not have model_verbosity');
|
||
});
|
||
|
||
test('does not emit service_tier or model_verbosity for heavy-tier agents (#774)', () => {
|
||
// gsd-planner has routingTier:"heavy" in model-catalog.json
|
||
const heavyAgent = `---
|
||
name: gsd-planner
|
||
description: Creates plans
|
||
tools: Read, Write, Edit
|
||
---
|
||
|
||
<role>You plan.</role>`;
|
||
const result = generateCodexAgentToml('gsd-planner', heavyAgent);
|
||
assert.ok(!result.includes('service_tier'), 'heavy-tier agent must not have service_tier');
|
||
assert.ok(!result.includes('model_verbosity'), 'heavy-tier agent must not have model_verbosity');
|
||
});
|
||
|
||
test('service_tier and model_verbosity appear before developer_instructions (#774)', () => {
|
||
const lightAgent = `---
|
||
name: gsd-plan-checker
|
||
description: Checks plans
|
||
---
|
||
|
||
<role>You check plans.</role>`;
|
||
const result = generateCodexAgentToml('gsd-plan-checker', lightAgent);
|
||
const stIdx = result.indexOf('service_tier = "flex"');
|
||
const mvIdx = result.indexOf('model_verbosity = "low"');
|
||
const instrIdx = result.indexOf("developer_instructions = '''");
|
||
assert.ok(stIdx !== -1, 'service_tier present');
|
||
assert.ok(mvIdx !== -1, 'model_verbosity present');
|
||
assert.ok(instrIdx !== -1, 'developer_instructions present');
|
||
assert.ok(stIdx < instrIdx, 'service_tier must appear before developer_instructions');
|
||
assert.ok(mvIdx < instrIdx, 'model_verbosity must appear before developer_instructions');
|
||
});
|
||
|
||
test('emitted TOML is parseable and contains correct field values for light-tier agents (#774)', () => {
|
||
const lightAgent = `---
|
||
name: gsd-codebase-mapper
|
||
description: Maps the codebase
|
||
---
|
||
|
||
<role>You map the codebase.</role>`;
|
||
const toml = generateCodexAgentToml('gsd-codebase-mapper', lightAgent);
|
||
const parsed = parseTomlToObject(toml);
|
||
assert.strictEqual(parsed.service_tier, 'flex', 'service_tier must parse to "flex"');
|
||
assert.strictEqual(parsed.model_verbosity, 'low', 'model_verbosity must parse to "low"');
|
||
});
|
||
});
|
||
|
||
// ─── #3897 rung 3 (ADR-3473 §8.3, HALT.md option 2): sandbox_mode derives from
|
||
// the tool contract, with 17 widening roles held at read-only ────────────────
|
||
//
|
||
// Spec: .gsd/phase/feat-3897-adr3473-83-rungs/{40-design,50-test-matrix}.md,
|
||
// rows S1-S9 / T20-T30. Measured against `next` @ ad6abc896 (HALT.md):
|
||
// deriving `workspace-write` iff an agent's frontmatter `tools:` declares
|
||
// `Write` or `Edit` reproduces all 11 CODEX_AGENT_SANDBOX map entries exactly,
|
||
// and would additionally widen 16 fallback roles that the map never covered.
|
||
// HALT.md's 16 was measured against a `tools:`-VALUE reader that only handled
|
||
// inline form; the #3897 list-form parse fix corrected `gsd-nyquist-auditor`'s
|
||
// YAML block-list `tools:` (previously misread as `"- Read"`, no Write/Edit
|
||
// found), which genuinely derives `workspace-write` and adds a 17th widening
|
||
// role. `CODEX_SANDBOX_HOLDS` (destructured above; `undefined` on the current
|
||
// tree) is this rung's pin list for those 17 — every row below that depends
|
||
// on it fails on its own `typeof` guard until it lands.
|
||
//
|
||
// Deriving-from-real-content is deliberate for T20/T21/T26/T27/T30: a
|
||
// synthetic `tools:` fixture cannot prove the CURRENT tree's byte output is
|
||
// preserved, only that the derivation LOGIC agrees with a made-up example.
|
||
|
||
// #3897 rung 3 — bin/install.js's `CODEX_AGENT_SANDBOX` map is DELETED
|
||
// (ADR-3473 §8.3): HALT.md measured that deriving `workspace-write` iff an
|
||
// agent's `tools:` frontmatter declares Write/Edit reproduces every one of
|
||
// these 11 entries exactly, with zero disagreements, making the hand-
|
||
// maintained map fully redundant. This literal is the pre-#3897
|
||
// `CODEX_AGENT_SANDBOX` contents, preserved here as a regression baseline —
|
||
// the only surviving copy of these 11 role -> mode pairs. T21 below and the
|
||
// `CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)` describe
|
||
// block both reference this ONE literal rather than duplicating it.
|
||
const PRE_3897_CODEX_AGENT_SANDBOX = {
|
||
'gsd-executor': 'workspace-write',
|
||
'gsd-planner': 'workspace-write',
|
||
'gsd-phase-researcher': 'workspace-write',
|
||
'gsd-project-researcher': 'workspace-write',
|
||
'gsd-research-synthesizer': 'workspace-write',
|
||
'gsd-verifier': 'workspace-write',
|
||
'gsd-codebase-mapper': 'workspace-write',
|
||
'gsd-roadmapper': 'workspace-write',
|
||
'gsd-debugger': 'workspace-write',
|
||
'gsd-plan-checker': 'read-only',
|
||
'gsd-integration-checker': 'read-only',
|
||
};
|
||
|
||
describe('#3897 rung 3: sandbox_mode derivation and the hold list', () => {
|
||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||
|
||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): T20/N6 used
|
||
// to iterate `Object.keys(EXPECTED_SANDBOX_BY_ROLE)` and then pin
|
||
// `assert.equal(checked, 35)` — that pins the FIXTURE, not the roster, so a
|
||
// 36th agent added to `agents/` would be silently unchecked by the entire
|
||
// rung-3 block instead of failing loudly. Driven from the real roster
|
||
// instead; a dedicated parity test below fails loudly, naming any file
|
||
// present in one set and not the other, the moment the two diverge.
|
||
const AGENT_ROSTER_ROLES = fs
|
||
.readdirSync(AGENTS_DIR)
|
||
.filter((f) => f.endsWith('.md'))
|
||
.map((f) => f.slice(0, -'.md'.length))
|
||
.sort();
|
||
|
||
function realAgentToolsRaw(agentName) {
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${agentName}.md`), 'utf8');
|
||
// #3897 list-form parse fix: route through the SAME shared extractor
|
||
// production uses, rather than a naive single-line regex here — a
|
||
// second, test-local reimplementation of "read the tools: value" is
|
||
// exactly the generative-fix-divergence shape that let this test's own
|
||
// `measuredWideningRoles` silently miss `gsd-nyquist-auditor` (YAML
|
||
// list-form `tools:`) before this fix.
|
||
return extractToolsValue(content) ?? '';
|
||
}
|
||
|
||
// #3897 rung 4 (isolated correctness review, NIT finding 7): this used to
|
||
// be a SEPARATE, simpler reimplementation of the real, private
|
||
// `_codexToolsDeclareWriteOrEdit` (codex-agent-toml.cts) — a naive
|
||
// comma-split-and-includes check that does not handle the real predicate's
|
||
// "except" negation form (F5). That is exactly the generative-fix-
|
||
// divergence shape CLAUDE.md warns about: a future change to the real
|
||
// predicate this copy does not mirror would silently disagree with it
|
||
// forever. `_codexToolsDeclareWriteOrEdit` is intentionally not exported
|
||
// (module-internal), so rather than reimplementing it a second time, this
|
||
// delegates to the REAL implementation through the public
|
||
// `deriveCodexSandboxMode`, pinned to an identity guaranteed to never be
|
||
// held or "suspicious" (F3) — with no hold in play,
|
||
// `deriveCodexSandboxMode(identity, toolsRaw) === 'workspace-write'` IS
|
||
// `_codexToolsDeclareWriteOrEdit(toolsRaw)`, byte for byte, because there is
|
||
// no second copy left to drift.
|
||
const PARITY_PROBE_IDENTITY = 'zzz-parity-probe-never-a-real-or-held-role';
|
||
function declaresWriteOrEdit(toolsRaw) {
|
||
return deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, toolsRaw) === 'workspace-write';
|
||
}
|
||
|
||
test('sanity: the parity-probe identity used by declaresWriteOrEdit is never itself held or suspicious', () => {
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, PARITY_PROBE_IDENTITY),
|
||
false,
|
||
'the probe identity must not collide with a real hold, or every T20/T21/T24 verdict derived from it would be silently wrong',
|
||
);
|
||
assert.equal(
|
||
deriveCodexSandboxMode(PARITY_PROBE_IDENTITY, 'Read, Write, Edit'),
|
||
'workspace-write',
|
||
'sanity: an unheld identity that declares Write/Edit must derive workspace-write',
|
||
);
|
||
});
|
||
|
||
// T20 (LOAD-BEARING, N6): frozen fixture of TODAY's real, per-role emitted
|
||
// sandbox_mode for all 35 roles in agents/ — captured from the CURRENT build
|
||
// by running the REAL generateCodexAgentToml against the REAL agent .md
|
||
// content (never a synthetic fixture) and committed here. This is the
|
||
// safety net for the refactor: it is true today (nothing has changed yet)
|
||
// and MUST remain true, per role, after sandbox_mode moves from the map to a
|
||
// derivation — an aggregate "35 roles emitted" count would pass even if one
|
||
// role silently widened; asserting per role (one test per role, mirroring
|
||
// codex-agent-toml.test.cjs's A14 round-trip pattern) does not let that hide.
|
||
const EXPECTED_SANDBOX_BY_ROLE = {
|
||
'gsd-advisor-researcher': 'read-only',
|
||
'gsd-ai-researcher': 'read-only',
|
||
'gsd-assumptions-analyzer': 'read-only',
|
||
'gsd-code-fixer': 'read-only',
|
||
'gsd-code-reviewer': 'read-only',
|
||
'gsd-codebase-mapper': 'workspace-write',
|
||
'gsd-debug-session-manager': 'read-only',
|
||
'gsd-debugger': 'workspace-write',
|
||
'gsd-doc-classifier': 'read-only',
|
||
'gsd-doc-synthesizer': 'read-only',
|
||
'gsd-doc-verifier': 'read-only',
|
||
'gsd-doc-writer': 'read-only',
|
||
'gsd-dom-verifier': 'read-only',
|
||
'gsd-domain-researcher': 'read-only',
|
||
'gsd-eval-auditor': 'read-only',
|
||
'gsd-eval-planner': 'read-only',
|
||
'gsd-executor': 'workspace-write',
|
||
'gsd-framework-selector': 'read-only',
|
||
'gsd-integration-checker': 'read-only',
|
||
'gsd-intel-updater': 'read-only',
|
||
'gsd-mempalace-curator': 'read-only',
|
||
'gsd-nyquist-auditor': 'read-only',
|
||
'gsd-pattern-mapper': 'read-only',
|
||
'gsd-phase-researcher': 'workspace-write',
|
||
'gsd-plan-checker': 'read-only',
|
||
'gsd-planner': 'workspace-write',
|
||
'gsd-project-researcher': 'workspace-write',
|
||
'gsd-research-synthesizer': 'workspace-write',
|
||
'gsd-roadmapper': 'workspace-write',
|
||
'gsd-security-auditor': 'read-only',
|
||
'gsd-ui-auditor': 'read-only',
|
||
'gsd-ui-checker': 'read-only',
|
||
'gsd-ui-researcher': 'read-only',
|
||
'gsd-user-profiler': 'read-only',
|
||
'gsd-verifier': 'workspace-write',
|
||
};
|
||
|
||
// The 17 roles that widen: declare Write/Edit, not in the pre-#3897
|
||
// CODEX_AGENT_SANDBOX map (now deleted; PRE_3897_CODEX_AGENT_SANDBOX above
|
||
// is the surviving baseline of its contents), so today's `|| 'read-only'`
|
||
// fallback under-grants them (16 measured by HALT.md + gsd-nyquist-auditor,
|
||
// whose YAML block-list `tools:` the pre-fix single-line reader misread as
|
||
// `"- Read"`, hiding its Write/Edit declaration). Computed here from the REAL
|
||
// agents/*.md tools frontmatter and that baseline — never hand-copied from
|
||
// HALT.md's prose — so this list cannot silently drift from what agents/
|
||
// actually declares (T30: "derived, not a second hardcoded copy").
|
||
const measuredWideningRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).filter((role) => {
|
||
const declaresWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
|
||
const inOldMap = Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role);
|
||
return declaresWrite && !inOldMap;
|
||
});
|
||
|
||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): the roster
|
||
// and the expectation table must cover EXACTLY the same set of roles — a
|
||
// role present in one and not the other fails loudly here, by name,
|
||
// instead of being silently unchecked by the whole rung-3 block.
|
||
test('T20 roster parity: EXPECTED_SANDBOX_BY_ROLE covers exactly the real agents/ roster, no more, no fewer', () => {
|
||
const expectedRoles = Object.keys(EXPECTED_SANDBOX_BY_ROLE).sort();
|
||
const inRosterNotExpected = AGENT_ROSTER_ROLES.filter((r) => !expectedRoles.includes(r));
|
||
const inExpectedNotRoster = expectedRoles.filter((r) => !AGENT_ROSTER_ROLES.includes(r));
|
||
assert.deepEqual(
|
||
inRosterNotExpected,
|
||
[],
|
||
`agents/ contains role(s) with no EXPECTED_SANDBOX_BY_ROLE entry: ${JSON.stringify(inRosterNotExpected)} — add them or this rung's coverage silently skips them`,
|
||
);
|
||
assert.deepEqual(
|
||
inExpectedNotRoster,
|
||
[],
|
||
`EXPECTED_SANDBOX_BY_ROLE names role(s) no longer present in agents/: ${JSON.stringify(inExpectedNotRoster)} — remove the stale entry`,
|
||
);
|
||
});
|
||
|
||
for (const role of AGENT_ROSTER_ROLES) {
|
||
test(`T20 everyRoleEmitsTheSameSandboxAsBefore: ${role} emits sandbox_mode="${EXPECTED_SANDBOX_BY_ROLE[role]}" byte-identically`, () => {
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
|
||
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
|
||
);
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||
const toml = generateCodexAgentToml(role, content);
|
||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||
assert.ok(match, `${role}'s emitted TOML must contain a sandbox_mode line`);
|
||
assert.equal(
|
||
match[1],
|
||
EXPECTED_SANDBOX_BY_ROLE[role],
|
||
`${role} must emit the SAME sandbox_mode after the derivation refactor lands — a per-role regression, not an aggregate count`,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('T30 holdListMatchesTheMeasuredWideningSet: CODEX_SANDBOX_HOLDS is exactly the 17 measured widening roles, derived not hardcoded twice', () => {
|
||
assert.equal(
|
||
typeof CODEX_SANDBOX_HOLDS,
|
||
'object',
|
||
'install.js must export CODEX_SANDBOX_HOLDS — the hold list does not exist yet',
|
||
);
|
||
assert.notEqual(CODEX_SANDBOX_HOLDS, null);
|
||
assert.deepEqual(
|
||
Object.keys(CODEX_SANDBOX_HOLDS).sort(),
|
||
measuredWideningRoles.sort(),
|
||
'CODEX_SANDBOX_HOLDS must equal exactly the set of roles that declare Write/Edit but were never in the old map — no more, no fewer',
|
||
);
|
||
// 16 measured by HALT.md against a single-line tools: reader + 1
|
||
// (gsd-nyquist-auditor, YAML block-list tools: — the list-form parse fix)
|
||
// = 17. `measuredWideningRoles` is computed from realAgentToolsRaw, which
|
||
// now routes through the fixed extractToolsValue, so this count moves
|
||
// WITH the parser fix rather than needing a second hand-edit.
|
||
assert.equal(measuredWideningRoles.length, 17, 'sanity: 17 widening roles against the current agents/ tree, once list-form tools: parses correctly');
|
||
});
|
||
|
||
test('T21 mappedRolesDeriveToTheirFormerValue: every former CODEX_AGENT_SANDBOX entry (11) derives to the identical value from its real tool contract', () => {
|
||
for (const [role, formerValue] of Object.entries(PRE_3897_CODEX_AGENT_SANDBOX)) {
|
||
const derivesWorkspaceWrite = declaresWriteOrEdit(realAgentToolsRaw(role));
|
||
const derived = derivesWorkspaceWrite ? 'workspace-write' : 'read-only';
|
||
assert.equal(
|
||
derived,
|
||
formerValue,
|
||
`${role}: the tool-contract derivation must reproduce the former map value exactly (HALT.md: zero disagreements across all 11)`,
|
||
);
|
||
}
|
||
assert.equal(Object.keys(PRE_3897_CODEX_AGENT_SANDBOX).length, 11);
|
||
});
|
||
|
||
test('T22 nonWritingFallbackRoleDerivesReadOnly: a fallback role declaring neither Write nor Edit derives read-only (S2)', () => {
|
||
const role = 'gsd-user-profiler'; // tools: Read (no Write/Edit), never in the map
|
||
assert.equal(Object.prototype.hasOwnProperty.call(PRE_3897_CODEX_AGENT_SANDBOX, role), false);
|
||
assert.equal(declaresWriteOrEdit(realAgentToolsRaw(role)), false);
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||
const toml = generateCodexAgentToml(role, content);
|
||
assert.ok(toml.includes('sandbox_mode = "read-only"'));
|
||
});
|
||
|
||
test('T23 heldRoleStaysReadOnlyWithARecordedReason: one of the 17 held roles stays read-only via an explicit, reasoned hold (S3)', () => {
|
||
assert.equal(typeof CODEX_SANDBOX_HOLDS, 'object', 'CODEX_SANDBOX_HOLDS does not exist yet');
|
||
const role = 'gsd-doc-writer'; // declares Write+Edit; one of HALT.md's 16
|
||
assert.ok(declaresWriteOrEdit(realAgentToolsRaw(role)), 'sanity: this role must actually declare a writing tool');
|
||
assert.ok(Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, role), `${role} must be an explicit hold entry`);
|
||
const entry = CODEX_SANDBOX_HOLDS[role];
|
||
const reason = typeof entry === 'string' ? entry : entry && entry.reason;
|
||
assert.equal(typeof reason, 'string', `the hold for ${role} must carry a recorded reason string, not a bare boolean pin`);
|
||
assert.ok(reason.length > 0);
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||
const toml = generateCodexAgentToml(role, content);
|
||
assert.ok(toml.includes('sandbox_mode = "read-only"'), 'byte-identical output despite the hold, per N6');
|
||
});
|
||
|
||
test('T24 staleHoldFailsRatherThanBeingHonored: a hold whose role no longer derives broader must FAIL, naming the role (S4)', () => {
|
||
assert.equal(
|
||
typeof CODEX_SANDBOX_HOLDS,
|
||
'object',
|
||
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for staleness',
|
||
);
|
||
// Every REAL hold entry, right now, must still derive workspace-write from
|
||
// the tool contract. A hold for a role whose tools no longer declare
|
||
// Write/Edit is exactly the staleness this row exists to catch; without
|
||
// this assertion the hold list is honored unconditionally forever, which
|
||
// is the hand-maintained-subset-map defect this rung deletes, rebuilt one
|
||
// list later (the ledger claim HALT.md makes).
|
||
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
|
||
assert.ok(
|
||
declaresWriteOrEdit(realAgentToolsRaw(role)),
|
||
`stale hold: ${role} is pinned to read-only but its CURRENT tool contract no longer declares Write/Edit — this hold must fail validation, not be silently honored`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('T25 holdForUnknownRoleFails: a hold naming a role that no longer exists in agents/ must FAIL (S5)', () => {
|
||
assert.equal(
|
||
typeof CODEX_SANDBOX_HOLDS,
|
||
'object',
|
||
'CODEX_SANDBOX_HOLDS does not exist yet, so there is nothing to validate for an unknown role',
|
||
);
|
||
for (const role of Object.keys(CODEX_SANDBOX_HOLDS)) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(AGENTS_DIR, `${role}.md`)),
|
||
`stale hold: ${role} is pinned but no longer exists in agents/ — this hold must fail validation`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('T26 newWritingRoleGetsTheContractNotThePin: a brand-new agent declaring Write, with no hold, derives workspace-write (S6) — RED today, falls back to read-only (#2540)', () => {
|
||
const newAgentContent = `---
|
||
name: gsd-totally-new-agent
|
||
description: A brand-new writing agent that has never been in the map or a hold
|
||
tools: Read, Write, Bash
|
||
---
|
||
|
||
<role>You are a brand-new agent.</role>`;
|
||
// Today: CODEX_AGENT_SANDBOX['gsd-totally-new-agent'] is undefined, so the
|
||
// `|| 'read-only'` fallback silently under-grants it — the exact defect
|
||
// §8.3 exists to fix (24 of 35 roles fell through this fallback).
|
||
const toml = generateCodexAgentToml('gsd-totally-new-agent', newAgentContent);
|
||
assert.ok(
|
||
toml.includes('sandbox_mode = "workspace-write"'),
|
||
'a new agent declaring Write, absent from both the map and any hold, must derive workspace-write from its own tool contract — not silently fall back to read-only',
|
||
);
|
||
});
|
||
|
||
test('T27 absentToolContractIsNotAGrant: an agent with no tools: frontmatter at all derives read-only (N8/S7)', () => {
|
||
const noToolsContent = `---
|
||
name: gsd-no-contract-agent
|
||
description: Declares no tools frontmatter key at all
|
||
---
|
||
|
||
<role>You have no declared tools.</role>`;
|
||
const toml = generateCodexAgentToml('gsd-no-contract-agent', noToolsContent);
|
||
assert.ok(
|
||
toml.includes('sandbox_mode = "read-only"'),
|
||
'absence of a tools: contract must never be read as a grant of workspace-write',
|
||
);
|
||
});
|
||
|
||
// #3897 security review follow-up (post-merge blocker): `bin/install.js`'s
|
||
// Codex install loop used to key the CODEX_SANDBOX_HOLDS lookup off the
|
||
// agent's OWN frontmatter `name:` field (`extractFrontmatterField(frontmatter,
|
||
// 'name') || file.replace('.md', '')`) rather than its filename — so editing,
|
||
// or merely recasing, a held role's `name:` field (same file, same tool
|
||
// contract) silently derived `workspace-write` instead of the pinned
|
||
// `read-only`. Unlike the deleted CODEX_AGENT_SANDBOX map (an ALLOWLIST whose
|
||
// unmatched-key fallback was `read-only`, i.e. safe), CODEX_SANDBOX_HOLDS is a
|
||
// SUBTRACTION from a derivation that defaults to `workspace-write`, so the
|
||
// identical lookup-key mismatch now fails OPEN — a severity flip. The fix
|
||
// keys the hold off the canonical source FILENAME stem (what
|
||
// `validateCodexSandboxHolds` already verifies exists), threaded through
|
||
// `installCodexConfig`'s per-file loop independently of the frontmatter
|
||
// `name:` used for the TOML body, with a case-insensitive lookup as a second
|
||
// line of defense.
|
||
test('heldRoleCannotEscapeItsHoldByRenamingFrontmatter_3897: editing or recasing a held role\'s frontmatter name: must not change its sandbox_mode from read-only', () => {
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
const heldRole = 'gsd-doc-writer'; // one of the 17 CODEX_SANDBOX_HOLDS entries
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, heldRole),
|
||
`sanity: ${heldRole} must be a real CODEX_SANDBOX_HOLDS entry`,
|
||
);
|
||
|
||
const variants = [
|
||
{ label: 'frontmatter name: edited to a different value', newName: 'gsd-doc-writer-x' },
|
||
{ label: 'frontmatter name: merely recased', newName: 'GSD-Doc-Writer' },
|
||
];
|
||
|
||
for (const { label, newName } of variants) {
|
||
const tmpAgentsSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-tamper-src-'));
|
||
const tmpDest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-tamper-dest-'));
|
||
try {
|
||
fs.cpSync(AGENTS_DIR, tmpAgentsSrc, { recursive: true });
|
||
const rolePath = path.join(tmpAgentsSrc, `${heldRole}.md`);
|
||
const original = fs.readFileSync(rolePath, 'utf8');
|
||
const tampered = original.replace(/^name:\s*.*$/m, `name: ${newName}`);
|
||
assert.notEqual(tampered, original, `sanity: the frontmatter name: line must actually change (${label})`);
|
||
fs.writeFileSync(rolePath, tampered);
|
||
|
||
// The FILE on disk is untouched (still `gsd-doc-writer.md`) — only its
|
||
// frontmatter content changed. validateCodexSandboxHolds only checks
|
||
// the file exists, so it does not (and should not) catch this by itself.
|
||
installCodexConfig(tmpDest, tmpAgentsSrc);
|
||
|
||
const emittedTomlPath = path.join(tmpDest, 'agents', `${newName}.toml`);
|
||
assert.ok(
|
||
fs.existsSync(emittedTomlPath),
|
||
`${label}: expected an emitted .toml at ${emittedTomlPath} (named after the tampered frontmatter name, per existing TOML-naming behavior — unrelated to this fix)`,
|
||
);
|
||
const toml = fs.readFileSync(emittedTomlPath, 'utf8');
|
||
const sandboxLine = toml.match(/^sandbox_mode = "([^"]{0,50})"$/m);
|
||
assert.ok(sandboxLine, `${label}: emitted .toml must contain a sandbox_mode line`);
|
||
assert.equal(
|
||
sandboxLine[1],
|
||
'read-only',
|
||
`${label}: a held role's sandbox_mode must stay read-only even when its frontmatter name: diverges ` +
|
||
`from its own filename — the hold is keyed off the file, not a self-declared field. Got: ${sandboxLine[1]}`,
|
||
);
|
||
} finally {
|
||
cleanup(tmpAgentsSrc);
|
||
cleanup(tmpDest);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('N6 (post-fix regression check): every real agents/ role remains byte-identical in emitted sandbox_mode after the filename-identity fix', () => {
|
||
// #3897 rung 4 (isolated correctness review, MINOR finding 6): driven
|
||
// from the real roster (AGENT_ROSTER_ROLES), not the hardcoded
|
||
// EXPECTED_SANDBOX_BY_ROLE key set — the roster-parity test above already
|
||
// fails loudly if the two sets ever diverge, so `checked` here is a
|
||
// genuine roster count, not a fixture-pinned literal that would silently
|
||
// stop growing when a 36th agent lands.
|
||
let checked = 0;
|
||
for (const role of AGENT_ROSTER_ROLES) {
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(EXPECTED_SANDBOX_BY_ROLE, role),
|
||
`${role} exists in agents/ but has no EXPECTED_SANDBOX_BY_ROLE entry — see the roster-parity test above`,
|
||
);
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||
const toml = generateCodexAgentToml(role, content);
|
||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||
assert.ok(match, `${role} must emit a sandbox_mode line`);
|
||
assert.equal(
|
||
match[1],
|
||
EXPECTED_SANDBOX_BY_ROLE[role],
|
||
`${role}: sandbox_mode must not drift as a side effect of the #3897 filename-identity fix`,
|
||
);
|
||
checked++;
|
||
}
|
||
assert.equal(
|
||
checked,
|
||
AGENT_ROSTER_ROLES.length,
|
||
'N6: every real role in agents/ must be checked and byte-identical',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #3897 security review F1/F3/F4/F5 regressions ─────────────────────────
|
||
//
|
||
// Isolated security review of this rung found a fail-open: `bin/install.js`'s
|
||
// Codex emit loop DECIDED sandbox_mode for the source filename stem but
|
||
// APPLIED it to the emitted `.toml`'s path, which is keyed on the
|
||
// frontmatter `name:` value instead — so a renamed file, or a sibling file
|
||
// whose `name:` collides with a held role, could land a held role's own
|
||
// artifact at `workspace-write`. F1(a)/F1(b) below assert on the EMITTED
|
||
// ARTIFACT (the written `.toml`'s `sandbox_mode`), never on
|
||
// `deriveCodexSandboxMode`'s return value directly — the whole defect is
|
||
// that the derivation and the emitted artifact could disagree.
|
||
describe('#3897 security review: F1 filename/name identity confusion, F3 confusables, F4/F5 totality', () => {
|
||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||
const {
|
||
installCodexConfig,
|
||
deriveCodexSandboxMode: deriveCodexSandboxModeLocal,
|
||
} = require('../bin/install.js');
|
||
const {
|
||
normalizeSandboxIdentity,
|
||
isSandboxHeld,
|
||
extractToolsValue: extractToolsValueLocal,
|
||
} = require('../gsd-core/bin/lib/codex-agent-toml.cjs');
|
||
|
||
function sandboxModeOfToml(toml) {
|
||
const match = toml.match(/^sandbox_mode = "([^"]+)"$/m);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
test('F1(a) rename case: a source file whose FILENAME differs from a held role, but whose frontmatter name: IS the held role, emits read-only', () => {
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1a-src-'));
|
||
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1a-dest-'));
|
||
try {
|
||
const original = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-doc-writer.md'), 'utf8');
|
||
assert.ok(/^name:\s*gsd-doc-writer\s*$/m.test(original), 'sanity: fixture source declares name: gsd-doc-writer');
|
||
// Renamed FILE, unchanged frontmatter name: — the fileStem no longer
|
||
// matches the held role, but the emitted TOML is still named after
|
||
// `gsd-doc-writer` (the frontmatter name:).
|
||
fs.writeFileSync(path.join(src, 'gsd-doc-writer-v2.md'), original);
|
||
|
||
installCodexConfig(dest, src);
|
||
|
||
const emittedPath = path.join(dest, 'agents', 'gsd-doc-writer.toml');
|
||
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
|
||
const toml = fs.readFileSync(emittedPath, 'utf8');
|
||
assert.equal(
|
||
sandboxModeOfToml(toml),
|
||
'read-only',
|
||
'F1(a): a held role\'s own emitted .toml must stay read-only even when reached via a renamed source file whose filename stem is unheld',
|
||
);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanup(dest);
|
||
}
|
||
});
|
||
|
||
test('F1(b) sibling-clobber case: a second, unheld source file whose frontmatter name: IS a held role must not widen the held role\'s emitted .toml', () => {
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1b-src-'));
|
||
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-f1b-dest-'));
|
||
try {
|
||
const heldOriginal = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-doc-writer.md'), 'utf8');
|
||
fs.writeFileSync(path.join(src, 'gsd-doc-writer.md'), heldOriginal);
|
||
// A sibling file — different filename stem, no hold entry for IT — but
|
||
// its frontmatter `name:` collides with the held role. Named to sort
|
||
// AFTER `gsd-doc-writer.md` (readdirSync/emit-loop order is
|
||
// alphabetical) so this sibling's write is the LAST one to the shared
|
||
// `gsd-doc-writer.toml` output path — the exact ordering the pre-fix
|
||
// defect needed to actually clobber the held role's own artifact
|
||
// (a sibling sorting BEFORE it gets silently overwritten again by the
|
||
// legitimate file's own correct read-only write, masking the bug).
|
||
const sibling = [
|
||
'---',
|
||
'name: gsd-doc-writer',
|
||
'description: sibling file colliding on frontmatter name',
|
||
'tools: Read, Write, Edit',
|
||
'---',
|
||
'',
|
||
'<role>sibling</role>',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(src, 'gsd-zzz-attacker-clone.md'), sibling);
|
||
|
||
installCodexConfig(dest, src);
|
||
|
||
const emittedPath = path.join(dest, 'agents', 'gsd-doc-writer.toml');
|
||
assert.ok(fs.existsSync(emittedPath), `expected an emitted .toml at ${emittedPath}`);
|
||
const toml = fs.readFileSync(emittedPath, 'utf8');
|
||
assert.equal(
|
||
sandboxModeOfToml(toml),
|
||
'read-only',
|
||
'F1(b): a sibling file whose frontmatter name: collides with a held role must not clobber that role\'s emitted .toml with workspace-write',
|
||
);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanup(dest);
|
||
}
|
||
});
|
||
|
||
const F3_CONFUSABLE_VECTORS = [
|
||
['Turkish dotted I (İ)', 'gsd-doc-wrİter'],
|
||
['Turkish dotless i (ı)', 'gsd-doc-wrıter'],
|
||
['fullwidth leading g (g)', 'gsd-doc-writer'],
|
||
['NFD combining acute on r (writeŕ)', 'gsd-doc-writeŕ'],
|
||
['trailing ASCII space', 'gsd-doc-writer '],
|
||
['trailing NBSP', 'gsd-doc-writer '],
|
||
['trailing dot', 'gsd-doc-writer.'],
|
||
['trailing newline', 'gsd-doc-writer\n'],
|
||
['trailing carriage return', 'gsd-doc-writer\r'],
|
||
['relative-path prefix ./', './gsd-doc-writer'],
|
||
['path traversal ../agents/', '../agents/gsd-doc-writer'],
|
||
];
|
||
|
||
for (const [label, vector] of F3_CONFUSABLE_VECTORS) {
|
||
test(`F3 confusable/whitespace/path vector — ${label} — derives read-only`, () => {
|
||
const mode = deriveCodexSandboxModeLocal(vector, 'Read, Write, Edit');
|
||
assert.equal(
|
||
mode,
|
||
'read-only',
|
||
`F3: identity ${JSON.stringify(vector)} (${label}) must derive read-only — either it normalizes onto the real held key, or it is unrecognizable and must fail closed`,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('F3: isSandboxHeld flags each confusable vector as held or suspicious (never silently neither)', () => {
|
||
for (const [label, vector] of F3_CONFUSABLE_VECTORS) {
|
||
const { held, suspicious } = isSandboxHeld(vector);
|
||
assert.ok(held || suspicious, `${label} (${JSON.stringify(vector)}) must be held or suspicious`);
|
||
}
|
||
});
|
||
|
||
test('F5: "All tools except Write, Edit" derives read-only (negation excludes Write/Edit)', () => {
|
||
const mode = deriveCodexSandboxModeLocal('gsd-negation-agent', 'All tools except Write, Edit');
|
||
assert.equal(mode, 'read-only', 'excluding Write and Edit after "except" must derive read-only');
|
||
});
|
||
|
||
test('F5: "All tools except Agent" derives workspace-write (Write/Edit are not excluded)', () => {
|
||
const mode = deriveCodexSandboxModeLocal('gsd-negation-agent', 'All tools except Agent');
|
||
assert.equal(mode, 'workspace-write', 'excluding only Agent leaves Write/Edit granted, so this must still derive workspace-write');
|
||
});
|
||
|
||
test('totality: deriveCodexSandboxMode never throws for undefined/null/[]/[undefined] identities', () => {
|
||
for (const identity of [undefined, null, [], [undefined]]) {
|
||
assert.doesNotThrow(
|
||
() => deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit'),
|
||
`deriveCodexSandboxMode must not throw for identity ${JSON.stringify(identity)}`,
|
||
);
|
||
const result = deriveCodexSandboxModeLocal(identity, 'Read, Write, Edit');
|
||
assert.equal(typeof result, 'string', `deriveCodexSandboxMode must return a string for identity ${JSON.stringify(identity)}`);
|
||
}
|
||
});
|
||
|
||
// #3897 rung 4 (isolated correctness review, MINOR finding 4): two exotic
|
||
// identity shapes more adversarial than the undefined/null/[]/[undefined]
|
||
// set above — an object whose OWN toString throws, and a null-prototype
|
||
// object (no inherited Object.prototype methods at all, so even a
|
||
// defensive `.toString`/`.hasOwnProperty` call site would blow up). Both
|
||
// must derive a string, never throw — "TOTAL for any input" per this
|
||
// function's own docstring is not just for well-behaved falsy/array shapes.
|
||
test('totality: deriveCodexSandboxMode never throws for an identity with a throwing toString', () => {
|
||
const hostile = { toString() { throw new Error('id-boom'); } };
|
||
assert.doesNotThrow(
|
||
() => deriveCodexSandboxModeLocal(hostile, 'Read'),
|
||
'deriveCodexSandboxMode must not throw for an identity whose toString throws',
|
||
);
|
||
assert.equal(typeof deriveCodexSandboxModeLocal(hostile, 'Read'), 'string');
|
||
});
|
||
|
||
test('totality: deriveCodexSandboxMode never throws for a null-prototype identity object', () => {
|
||
const nullProto = Object.create(null);
|
||
assert.doesNotThrow(
|
||
() => deriveCodexSandboxModeLocal(nullProto, 'Read'),
|
||
'deriveCodexSandboxMode must not throw for a null-prototype identity object',
|
||
);
|
||
assert.equal(typeof deriveCodexSandboxModeLocal(nullProto, 'Read'), 'string');
|
||
});
|
||
|
||
test('totality: normalizeSandboxIdentity never throws for undefined/null/non-string input', () => {
|
||
for (const raw of [undefined, null, 42, {}, []]) {
|
||
assert.doesNotThrow(() => normalizeSandboxIdentity(raw), `must not throw for ${JSON.stringify(raw)}`);
|
||
assert.equal(normalizeSandboxIdentity(raw), null, `non-string input ${JSON.stringify(raw)} must normalize to null`);
|
||
}
|
||
});
|
||
|
||
test('F4: extractToolsValue returns undefined (never throws) for undefined/null/Buffer input', () => {
|
||
for (const value of [undefined, null, Buffer.from('tools: Write')]) {
|
||
assert.doesNotThrow(() => extractToolsValueLocal(value), `extractToolsValue must not throw for ${String(value)}`);
|
||
assert.equal(extractToolsValueLocal(value), undefined, `extractToolsValue must return undefined for non-string input ${String(value)}`);
|
||
}
|
||
});
|
||
|
||
test('F4: extractToolsValue still returns the parsed value for real string content — inline form (no regression)', () => {
|
||
const content = ['---', 'tools: Read, Write', '---', ''].join('\n');
|
||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||
});
|
||
|
||
// ─── #3897 list-form parse fix: YAML block-list `tools:` ─────────────────
|
||
//
|
||
// `agents/gsd-nyquist-auditor.md` and `agents/gsd-security-auditor.md` are
|
||
// the only two roster files using this shape. The pre-fix single-line
|
||
// regex `/^tools:\s*(.+)$/m` let `\s*` swallow the newline after a bare
|
||
// `tools:` key and matched into the FIRST list item's own line, returning
|
||
// just `"- Read"` — a real Write/Edit DECLARATION read as an absence.
|
||
|
||
test('list-form: a tools: block list declaring Write derives workspace-write (FAILS before the parse fix — pre-fix reader returned "- Read")', () => {
|
||
const content = [
|
||
'---',
|
||
'name: gsd-list-form-writer',
|
||
'tools:',
|
||
' - Read',
|
||
' - Write',
|
||
' - Edit',
|
||
' - Bash',
|
||
'---',
|
||
'',
|
||
'<role>list-form writer</role>',
|
||
].join('\n');
|
||
const toolsRaw = extractToolsValueLocal(content);
|
||
assert.equal(toolsRaw, 'Read, Write, Edit, Bash', 'list items must be joined the same way the comma-tokenizer downstream expects');
|
||
assert.equal(
|
||
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
|
||
'workspace-write',
|
||
'a tools: block list declaring Write must derive workspace-write, not read-only from a truncated first-item read',
|
||
);
|
||
});
|
||
|
||
test('list-form: a tools: block list with NO write tool derives read-only (no over-correction)', () => {
|
||
const content = [
|
||
'---',
|
||
'name: gsd-list-form-reader',
|
||
'tools:',
|
||
' - Read',
|
||
' - Bash',
|
||
' - Glob',
|
||
'---',
|
||
'',
|
||
'<role>list-form reader</role>',
|
||
].join('\n');
|
||
const toolsRaw = extractToolsValueLocal(content);
|
||
assert.equal(toolsRaw, 'Read, Bash, Glob');
|
||
assert.equal(
|
||
deriveCodexSandboxModeLocal('zzz-list-form-probe-never-held', toolsRaw),
|
||
'read-only',
|
||
'a list-form tools: with no Write/Edit item must still derive read-only',
|
||
);
|
||
});
|
||
|
||
test('list-form: the list terminates at the next frontmatter key and does not swallow its value', () => {
|
||
const content = [
|
||
'---',
|
||
'name: gsd-list-form-terminates',
|
||
'tools:',
|
||
' - Read',
|
||
' - Write',
|
||
'color: blue',
|
||
'---',
|
||
'',
|
||
'<role>list-form terminates before a sibling key</role>',
|
||
].join('\n');
|
||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||
// The sibling key's own value must remain independently readable — it
|
||
// must never have been consumed as a phantom third list item.
|
||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||
assert.equal(extractFrontmatterField(frontmatter, 'color'), 'blue');
|
||
});
|
||
|
||
test('list-form: the list terminates at the closing --- and does not run past the frontmatter', () => {
|
||
const content = [
|
||
'---',
|
||
'name: gsd-list-form-eof',
|
||
'tools:',
|
||
' - Read',
|
||
' - Write',
|
||
'---',
|
||
'- this looks like a list item but is BODY text, not frontmatter',
|
||
].join('\n');
|
||
assert.equal(extractToolsValueLocal(content), 'Read, Write');
|
||
});
|
||
|
||
test('roster truth: gsd-nyquist-auditor DERIVES workspace-write from its real tool contract AND is HELD, so its emitted .toml stays read-only', () => {
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-nyquist-auditor.md'), 'utf8');
|
||
const toolsRaw = extractToolsValueLocal(content);
|
||
assert.ok(
|
||
/\bWrite\b/.test(toolsRaw) && /\bEdit\b/.test(toolsRaw),
|
||
`sanity: gsd-nyquist-auditor's real tools: must declare both Write and Edit, got ${JSON.stringify(toolsRaw)}`,
|
||
);
|
||
// Derivation WITHOUT the hold (an identity guaranteed never held/suspicious,
|
||
// same probe idiom as the rung-3 describe block's PARITY_PROBE_IDENTITY)
|
||
// must show the role genuinely derives workspace-write from its contract —
|
||
// this is what proves derive-and-hold is doing real work, not that the
|
||
// parser happens to agree with the pin by accident.
|
||
assert.equal(
|
||
deriveCodexSandboxModeLocal('zzz-nyquist-unheld-probe-never-a-real-role', toolsRaw),
|
||
'workspace-write',
|
||
'gsd-nyquist-auditor must genuinely derive workspace-write from its tool contract once list-form tools: parses correctly',
|
||
);
|
||
// The REAL identity IS held, so the actual emitted artifact stays read-only.
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(CODEX_SANDBOX_HOLDS, 'gsd-nyquist-auditor'),
|
||
'gsd-nyquist-auditor must be an explicit CODEX_SANDBOX_HOLDS entry',
|
||
);
|
||
const { generateCodexAgentToml: generateCodexAgentTomlLocal } = require('../bin/install.js');
|
||
const toml = generateCodexAgentTomlLocal('gsd-nyquist-auditor', content);
|
||
assert.ok(
|
||
toml.includes('sandbox_mode = "read-only"'),
|
||
'gsd-nyquist-auditor\'s emitted .toml must stay read-only (held), even though it now derives workspace-write',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #3241: shared isAnthropicFlavoredModel / CLAUDE_AGENT_ALIASES surface ─────
|
||
// Phase 2/3 need one predicate. 40-design.md's "seam decision" moves
|
||
// CLAUDE_AGENT_ALIASES into model-catalog.cjs and defines isAnthropicFlavoredModel
|
||
// beside it, re-exporting from model-resolver.cjs for back-compat. Neither exists
|
||
// on model-catalog.cjs yet (verified: modelCatalog.isAnthropicFlavoredModel is
|
||
// `undefined` today), so every test below is RED against the current tree.
|
||
|
||
describe('#3241 isAnthropicFlavoredModel + CLAUDE_AGENT_ALIASES (model-catalog owns it)', () => {
|
||
const parityAgent = `---
|
||
name: gsd-executor
|
||
description: Executes plans
|
||
tools: Read, Write
|
||
---
|
||
|
||
<role>You are an executor.</role>`;
|
||
|
||
test('predicate flags every Claude tier alias and case/namespace variant (#3241)', () => {
|
||
// RED (pre-fix): modelCatalog.isAnthropicFlavoredModel is undefined today,
|
||
// so `modelCatalog.isAnthropicFlavoredModel('opus')` throws
|
||
// "isAnthropicFlavoredModel is not a function" — this test fails on the
|
||
// very first call, before any assertion runs.
|
||
for (const alias of ['opus', 'sonnet', 'haiku', 'fable']) {
|
||
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(alias), true, `alias "${alias}" must be flagged`);
|
||
}
|
||
for (const id of ['claude-opus-4-5', 'anthropic/claude-x', 'us.anthropic.claude-x', 'CLAUDE-X']) {
|
||
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(id), true, `id "${id}" must be flagged (case/namespace variant)`);
|
||
}
|
||
});
|
||
|
||
test('predicate is false for real Codex ids and non-strings, without throwing (#3241)', () => {
|
||
// RED (pre-fix): same "not a function" throw as above — fails before any
|
||
// assertion is reached.
|
||
for (const value of ['gpt-5.6-sol', 'gpt-4', '', null, undefined, {}, 0]) {
|
||
assert.doesNotThrow(() => modelCatalog.isAnthropicFlavoredModel(value), `must not throw for ${JSON.stringify(value)}`);
|
||
assert.strictEqual(modelCatalog.isAnthropicFlavoredModel(value), false, `must be false for ${JSON.stringify(value)}`);
|
||
}
|
||
});
|
||
|
||
test('the alias set has exactly one owner across both modules (#3241)', () => {
|
||
// RED (pre-fix): modelCatalog.CLAUDE_AGENT_ALIASES is undefined today
|
||
// (model-catalog.cjs exports no such key), so deepStrictEqual against
|
||
// modelResolver's real Set fails. Divergence guard per
|
||
// 50-test-matrix.md rows 22-23: without this, Phase 2/3 can silently
|
||
// fork the rule.
|
||
assert.deepStrictEqual(
|
||
modelCatalog.CLAUDE_AGENT_ALIASES,
|
||
modelResolver.CLAUDE_AGENT_ALIASES,
|
||
'model-catalog and model-resolver must share the exact same CLAUDE_AGENT_ALIASES contents'
|
||
);
|
||
});
|
||
|
||
test('installer Codex .toml emission agrees with the shared predicate (#3241)', () => {
|
||
// RED (pre-fix): modelCatalog.isAnthropicFlavoredModel is undefined, so
|
||
// the first loop iteration throws "not a function" before any
|
||
// generateCodexAgentToml call happens.
|
||
const probeValues = [...CLAUDE_AGENT_ALIASES, 'claude-sonnet-5', 'gpt-5.6-sol'];
|
||
for (const value of probeValues) {
|
||
const expectedFlavored = modelCatalog.isAnthropicFlavoredModel(value);
|
||
const result = generateCodexAgentToml('gsd-executor', parityAgent, { 'gsd-executor': value });
|
||
const modelLine = result.split(/\r?\n/).find((line) => /^model = /.test(line));
|
||
if (expectedFlavored) {
|
||
assert.strictEqual(modelLine, undefined, `"${value}" is Anthropic-flavored per the shared predicate — installer must omit it`);
|
||
} else {
|
||
assert.strictEqual(modelLine, `model = ${JSON.stringify(value)}`, `"${value}" is NOT Anthropic-flavored per the shared predicate — installer must emit it verbatim`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── sandboxTier gate on generateCodexAgentToml ────────────────────────────────
|
||
|
||
describe('generateCodexAgentToml sandboxTier gate', () => {
|
||
const sampleAgent = `---
|
||
name: gsd-executor
|
||
description: Executes plans
|
||
tools: Read, Write, Edit
|
||
color: yellow
|
||
---
|
||
|
||
<role>You are an executor.</role>`;
|
||
|
||
test('sandboxTier=none: does NOT emit sandbox_mode', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, null, null, 'none');
|
||
assert.ok(!result.includes('sandbox_mode'), 'sandbox_mode must be absent when sandboxTier is none');
|
||
});
|
||
|
||
test('sandboxTier=codex-agent-sandbox: emits sandbox_mode = "workspace-write"', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent, null, null, null, 'codex-agent-sandbox');
|
||
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'must emit workspace-write for codex-agent-sandbox tier');
|
||
});
|
||
|
||
test('default (no sandboxTier arg): still emits sandbox_mode = "workspace-write" (no-op for codex)', () => {
|
||
const result = generateCodexAgentToml('gsd-executor', sampleAgent);
|
||
assert.ok(result.includes('sandbox_mode = "workspace-write"'), 'default preserves codex behavior');
|
||
});
|
||
|
||
test('resolveInstallPlan projection: codex.sandboxTier === "codex-agent-sandbox"', () => {
|
||
const plan = resolveInstallPlan('codex');
|
||
assert.strictEqual(plan.sandboxTier, 'codex-agent-sandbox', 'codex must project sandboxTier=codex-agent-sandbox');
|
||
});
|
||
|
||
test('resolveInstallPlan projection: claude.sandboxTier === "none"', () => {
|
||
const plan = resolveInstallPlan('claude');
|
||
assert.strictEqual(plan.sandboxTier, 'none', 'claude must project sandboxTier=none');
|
||
});
|
||
});
|
||
|
||
// ─── installCodexConfig threading-seam: sandboxTier → per-agent TOML ─────────
|
||
|
||
describe('installCodexConfig sandboxTier threading seam', () => {
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
|
||
let tmpDir;
|
||
let agentsSrc;
|
||
let targetDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-sandboxtier-seam-'));
|
||
agentsSrc = path.join(tmpDir, 'agents');
|
||
targetDir = path.join(tmpDir, 'codex');
|
||
fs.mkdirSync(agentsSrc, { recursive: true });
|
||
fs.mkdirSync(targetDir, { recursive: true });
|
||
// Write a minimal gsd-executor agent fixture
|
||
fs.writeFileSync(path.join(agentsSrc, 'gsd-executor.md'), [
|
||
'---',
|
||
'name: gsd-executor',
|
||
'description: Executes plans',
|
||
'tools: Read, Write, Edit',
|
||
'---',
|
||
'',
|
||
'<role>You are an executor.</role>',
|
||
].join('\n'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('sandboxTier=none: written per-agent .toml does NOT contain sandbox_mode', () => {
|
||
installCodexConfig(targetDir, agentsSrc, 'none');
|
||
const tomlPath = path.join(targetDir, 'agents', 'gsd-executor.toml');
|
||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
|
||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||
assert.ok(!toml.includes('sandbox_mode'), 'sandbox_mode must be absent when sandboxTier=none');
|
||
});
|
||
|
||
test('sandboxTier=codex-agent-sandbox: written per-agent .toml contains sandbox_mode', () => {
|
||
installCodexConfig(targetDir, agentsSrc, 'codex-agent-sandbox');
|
||
const tomlPath = path.join(targetDir, 'agents', 'gsd-executor.toml');
|
||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
|
||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||
assert.ok(toml.includes('sandbox_mode'), 'sandbox_mode must be present when sandboxTier=codex-agent-sandbox');
|
||
});
|
||
|
||
test('default 2-arg form (no sandboxTier): written per-agent .toml contains sandbox_mode (codex default)', () => {
|
||
installCodexConfig(targetDir, agentsSrc);
|
||
const tomlPath = path.join(targetDir, 'agents', 'gsd-executor.toml');
|
||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML must be written');
|
||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||
assert.ok(toml.includes('sandbox_mode'), 'sandbox_mode must be present in default 2-arg form (codex-agent-sandbox default)');
|
||
});
|
||
});
|
||
|
||
// NOTE: A test for the new fail-loud throw on missing/invalid sandboxTier in
|
||
// resolveInstallPlan is omitted here. Constructing a descriptor without the
|
||
// field would require mocking the capability-registry module which is a
|
||
// singleton require(); patching it invasively would corrupt other tests in the
|
||
// same process. The throw path is verified at the type level (tsc) and by the
|
||
// build passing, and the happy-path coverage (claude.sandboxTier === 'none' and
|
||
// codex.sandboxTier === 'codex-agent-sandbox') confirms the real registry has
|
||
// valid values for all 15 runtimes.
|
||
|
||
// ─── CODEX_AGENT_SANDBOX (deleted map): derivation regression baseline ──────────
|
||
|
||
describe('CODEX_AGENT_SANDBOX (deleted map, derivation regression baseline)', () => {
|
||
// bin/install.js's CODEX_AGENT_SANDBOX map is gone (ADR-3473 §8.3, #3897
|
||
// rung 3) — deriveCodexSandboxMode is the sole owner of sandbox_mode now.
|
||
// These tests assert the SAME underlying property the deleted map's own
|
||
// suite used to assert (which 11 roles get which sandbox_mode), but against
|
||
// the real derivation and the real agents/*.md content, sourced from the
|
||
// PRE_3897_CODEX_AGENT_SANDBOX baseline literal above rather than the map.
|
||
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||
|
||
function realDerivedSandboxMode(role) {
|
||
const content = fs.readFileSync(path.join(AGENTS_DIR, `${role}.md`), 'utf8');
|
||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||
const toolsRaw = extractFrontmatterField(frontmatter || '', 'tools') || '';
|
||
return deriveCodexSandboxMode(role, toolsRaw);
|
||
}
|
||
|
||
test('has all 11 baseline agents', () => {
|
||
const agentNames = Object.keys(PRE_3897_CODEX_AGENT_SANDBOX);
|
||
assert.strictEqual(agentNames.length, 11, 'has 11 agents');
|
||
});
|
||
|
||
test('workspace-write agents still derive workspace-write', () => {
|
||
const writeAgents = [
|
||
'gsd-executor', 'gsd-planner', 'gsd-phase-researcher',
|
||
'gsd-project-researcher', 'gsd-research-synthesizer', 'gsd-verifier',
|
||
'gsd-codebase-mapper', 'gsd-roadmapper', 'gsd-debugger',
|
||
];
|
||
for (const name of writeAgents) {
|
||
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'workspace-write', `${name} baseline is workspace-write`);
|
||
assert.strictEqual(realDerivedSandboxMode(name), 'workspace-write', `${name} still derives workspace-write`);
|
||
}
|
||
});
|
||
|
||
test('read-only agents still derive read-only', () => {
|
||
const readOnlyAgents = ['gsd-plan-checker', 'gsd-integration-checker'];
|
||
for (const name of readOnlyAgents) {
|
||
assert.strictEqual(PRE_3897_CODEX_AGENT_SANDBOX[name], 'read-only', `${name} baseline is read-only`);
|
||
assert.strictEqual(realDerivedSandboxMode(name), 'read-only', `${name} still derives read-only`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── generateCodexConfigBlock ───────────────────────────────────────────────────
|
||
|
||
describe('generateCodexConfigBlock', () => {
|
||
const agents = [
|
||
{ name: 'gsd-executor', description: 'Executes plans' },
|
||
{ name: 'gsd-planner', description: 'Creates plans' },
|
||
];
|
||
|
||
test('starts with GSD marker', () => {
|
||
const result = generateCodexConfigBlock(agents);
|
||
assert.ok(result.startsWith(GSD_CODEX_MARKER), 'starts with marker');
|
||
});
|
||
|
||
test('emits the [agents] max_depth tuning block but no feature flags (#2088)', () => {
|
||
const result = generateCodexConfigBlock(agents);
|
||
assert.ok(!result.includes('[features]'), 'no features table');
|
||
assert.ok(!result.includes('multi_agent'), 'no multi_agent');
|
||
assert.ok(!result.includes('default_mode_request_user_input'), 'no request_user_input');
|
||
// #2088: the managed block pins dispatch depth via a bare [agents]
|
||
// AgentsToml scalar table. #2406: this is now the ONLY [agents]-namespaced
|
||
// content the block emits — no [agents.<name>] role structs.
|
||
assert.match(result, /^\[agents\]$/m, 'emits the [agents] tuning table');
|
||
assert.match(result, /^max_depth = 1$/m, 'pins max_depth = 1');
|
||
// Should not emit [[agents]] sequence format (rejected by Codex 0.124.0).
|
||
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format');
|
||
// Only max_depth is managed — max_threads is intentionally left to the user.
|
||
assert.ok(!result.includes('max_threads'), 'no max_threads (only max_depth is GSD-managed)');
|
||
});
|
||
|
||
test('#2406: does not emit [agents.<name>] role tables — the standalone agents/<name>.toml is the sole canonical source', () => {
|
||
const result = generateCodexConfigBlock(agents);
|
||
// Codex auto-discovers standalone TOMLs under $CODEX_HOME/agents/. A
|
||
// config.toml [agents.<name>] table pointing config_file back at that
|
||
// same file is a SECOND registration of the same role and made Codex log
|
||
// "Ignoring malformed agent role definition: duplicate agent role name"
|
||
// once per agent. Zero role headers and zero config_file lines proves
|
||
// the duplication is gone.
|
||
assert.ok(!result.includes('[agents.gsd-executor]'), 'no executor role header');
|
||
assert.ok(!result.includes('[agents.gsd-planner]'), 'no planner role header');
|
||
assert.ok(!result.includes('config_file'), 'no config_file line at all');
|
||
assert.ok(!result.includes('description = "Executes plans"'), 'no per-agent description leaks into config.toml');
|
||
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format either');
|
||
});
|
||
|
||
test('#2406: block is a valid TOML shape with exactly one [agents] table and zero [agents.*] sub-tables', () => {
|
||
const result = generateCodexConfigBlock(agents);
|
||
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format present');
|
||
const bareAgentsHeaders = (result.match(/^\[agents\]\s*$/gm) || []).length;
|
||
assert.strictEqual(bareAgentsHeaders, 1, 'exactly one bare [agents] dispatch-tuning table');
|
||
const structHeaders = (result.match(/^\[agents\.[^\]]+\]\s*$/gm) || []).length;
|
||
assert.strictEqual(structHeaders, 0, 'zero [agents.<name>] struct headers — role tables removed (#2406)');
|
||
});
|
||
|
||
test('#2406: output is unaffected by agents/targetDir — no per-agent content is derived from either', () => {
|
||
const withAgents = generateCodexConfigBlock(agents);
|
||
const withoutAgents = generateCodexConfigBlock([]);
|
||
assert.strictEqual(withAgents, withoutAgents, 'agents list no longer influences the emitted block');
|
||
|
||
const withTargetDir = generateCodexConfigBlock(agents, '/home/user/.codex');
|
||
assert.strictEqual(withTargetDir, withAgents, 'targetDir no longer influences the emitted block');
|
||
assert.ok(!withTargetDir.includes('config_file'), 'no config_file even when targetDir is provided');
|
||
assert.ok(!withTargetDir.includes('/home/user/.codex'), 'targetDir path does not leak into the block');
|
||
});
|
||
});
|
||
|
||
// ─── stripGsdFromCodexConfig ────────────────────────────────────────────────────
|
||
|
||
describe('stripGsdFromCodexConfig', () => {
|
||
test('returns null for GSD-only config', () => {
|
||
const content = `${GSD_CODEX_MARKER}\n[features]\nmulti_agent = true\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.strictEqual(result, null, 'returns null when GSD-only');
|
||
});
|
||
|
||
test('preserves user content before marker', () => {
|
||
const content = `[model]\nname = "o3"\n\n${GSD_CODEX_MARKER}\n[features]\nmulti_agent = true\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(result.includes('[model]'), 'preserves user section');
|
||
assert.ok(result.includes('name = "o3"'), 'preserves user values');
|
||
assert.ok(!result.includes('multi_agent'), 'removes GSD content');
|
||
assert.ok(!result.includes(GSD_CODEX_MARKER), 'removes marker');
|
||
});
|
||
|
||
test('strips injected feature keys without marker', () => {
|
||
const content = `[features]\nmulti_agent = true\ndefault_mode_request_user_input = true\nother_feature = false\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(!result.includes('multi_agent'), 'removes multi_agent');
|
||
assert.ok(!result.includes('default_mode_request_user_input'), 'removes request_user_input');
|
||
assert.ok(result.includes('other_feature = false'), 'preserves user features');
|
||
});
|
||
|
||
test('removes empty [features] section', () => {
|
||
const content = `[features]\nmulti_agent = true\n[model]\nname = "o3"\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(!result.includes('[features]'), 'removes empty features section');
|
||
assert.ok(result.includes('[model]'), 'preserves other sections');
|
||
});
|
||
|
||
test('strips injected keys above marker on uninstall', () => {
|
||
// Case 3 install injects keys into [features] AND appends marker block
|
||
const content = `[model]\nname = "o3"\n\n[features]\nmulti_agent = true\ndefault_mode_request_user_input = true\nsome_custom_flag = true\n\n${GSD_CODEX_MARKER}\n[agents]\nmax_threads = 4\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(result.includes('[model]'), 'preserves user model section');
|
||
assert.ok(result.includes('some_custom_flag = true'), 'preserves user feature');
|
||
assert.ok(!result.includes('multi_agent'), 'strips injected multi_agent');
|
||
assert.ok(!result.includes('default_mode_request_user_input'), 'strips injected request_user_input');
|
||
assert.ok(!result.includes(GSD_CODEX_MARKER), 'strips marker');
|
||
});
|
||
|
||
test('removes legacy [agents.gsd-*] map sections (self-heal pre-#2645 configs)', () => {
|
||
const content = `[agents.gsd-executor]\ndescription = "test"\nconfig_file = "agents/gsd-executor.toml"\n\n[agents.custom-agent]\ndescription = "user agent"\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(!result.includes('[agents.gsd-executor]'), 'removes legacy GSD agent map section');
|
||
assert.ok(result.includes('[agents.custom-agent]'), 'preserves user agent section');
|
||
});
|
||
|
||
test('#2645: removes [[agents]] array-of-tables entries whose name is gsd-*', () => {
|
||
const content = `[[agents]]\nname = "gsd-executor"\ndescription = "test"\nconfig_file = "agents/gsd-executor.toml"\n\n[[agents]]\nname = "custom-agent"\ndescription = "user agent"\n`;
|
||
const result = stripGsdFromCodexConfig(content);
|
||
assert.ok(!/name = "gsd-executor"/.test(result), 'removes managed GSD [[agents]] entry');
|
||
assert.ok(result.includes('name = "custom-agent"'), 'preserves user [[agents]] entry');
|
||
});
|
||
|
||
test('#2645: handles mixed legacy + new shapes and multiple user/gsd entries in one file', () => {
|
||
// Multiple GSD entries (both legacy map and new array-of-tables) interleaved
|
||
// with multiple user-authored agents in both shapes — none of the user
|
||
// entries may be removed and all GSD entries must be stripped.
|
||
const content = [
|
||
'[agents.gsd-executor]',
|
||
'description = "legacy gsd"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
'[agents.custom-legacy]',
|
||
'description = "user legacy"',
|
||
'',
|
||
'[[agents]]',
|
||
'name = "gsd-planner"',
|
||
'description = "new gsd"',
|
||
'',
|
||
'[[agents]]',
|
||
'name = "my-helper"',
|
||
'description = "user new"',
|
||
'',
|
||
'[[agents]]',
|
||
"name = 'gsd-debugger'",
|
||
'description = "single-quoted gsd"',
|
||
'',
|
||
'[[agents]]',
|
||
'name = "another-user"',
|
||
'description = "second user agent"',
|
||
'',
|
||
].join('\n');
|
||
const result = stripGsdFromCodexConfig(content);
|
||
// All GSD entries removed.
|
||
assert.ok(!result.includes('gsd-executor'), 'removes legacy gsd-executor');
|
||
assert.ok(!/name\s*=\s*"gsd-planner"/.test(result), 'removes new gsd-planner');
|
||
assert.ok(!/name\s*=\s*'gsd-debugger'/.test(result), 'removes single-quoted gsd-debugger');
|
||
// All user-authored entries preserved.
|
||
assert.ok(result.includes('[agents.custom-legacy]'), 'preserves user legacy [agents.custom-legacy]');
|
||
assert.ok(result.includes('user legacy'), 'preserves user legacy body');
|
||
assert.ok(result.includes('name = "my-helper"'), 'preserves user new [[agents]]');
|
||
assert.ok(result.includes('name = "another-user"'), 'preserves second user [[agents]]');
|
||
assert.ok(result.includes('second user agent'), 'preserves second user body');
|
||
});
|
||
});
|
||
|
||
// ─── migrateCodexHooksMapFormat ─────────────────────────────────────────────────
|
||
|
||
describe('migrateCodexHooksMapFormat', () => {
|
||
test('migrates flat [[hooks]] with event key to namespaced [[hooks.<EVENT>]] form', () => {
|
||
// Flat [[hooks]] + event = "..." is TOML-incompatible with [[hooks.SessionStart]],
|
||
// so migrateCodexHooksMapFormat now converts it to the nested namespaced form.
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'flat [[hooks]] event=SessionStart must be promoted to [[hooks.SessionStart]] AoT');
|
||
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
|
||
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
|
||
'must emit [[hooks.SessionStart.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command,
|
||
'node /home/.codex/hooks/gsd-check-update.js');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command',
|
||
'migrated handler must carry type = "command" per Codex 0.124.0+ schema');
|
||
assert.equal(parsed.hooks.SessionStart[0].event, undefined,
|
||
'event key consumed as namespace — must not appear in emitted block');
|
||
assert.ok(!Array.isArray(parsed.hooks), 'hooks must be a table, not a flat array');
|
||
assert.equal(parsed.features && parsed.features.codex_hooks, true);
|
||
});
|
||
|
||
test('returns content unchanged for empty string', () => {
|
||
assert.strictEqual(migrateCodexHooksMapFormat(''), '');
|
||
});
|
||
|
||
test('converts [hooks.shell] to namespaced AoT [[hooks.shell]] (#2760 CR5 finding 3)', () => {
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[hooks]',
|
||
'',
|
||
'[hooks.shell]',
|
||
'command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
// Parse structurally — no source-grep on raw bytes.
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell),
|
||
'hooks.shell must be an array of tables, got: ' + (parsed.hooks ? typeof parsed.hooks.shell : 'no hooks table'));
|
||
assert.strictEqual(parsed.hooks.shell.length, 1);
|
||
// #2773: command now lives in [[hooks.shell.hooks]] sub-table, not at event-entry level
|
||
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command, 'node /home/.codex/hooks/gsd-check-update.js');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command');
|
||
// No flat top-level [[hooks]] AoT and no synthetic event field.
|
||
assert.ok(!Array.isArray(parsed.hooks),
|
||
'no top-level [[hooks]] AoT — namespace IS the event in CR5 form');
|
||
assert.equal(parsed.hooks.shell[0].event, undefined,
|
||
'no synthetic event field — namespace [[hooks.shell]] encodes the event');
|
||
// User content preserved.
|
||
assert.equal(parsed.features && parsed.features.codex_hooks, true);
|
||
});
|
||
|
||
test('converts [hooks.exec] to namespaced AoT [[hooks.exec]] (#2760 CR5 finding 3)', () => {
|
||
const content = [
|
||
'[hooks.exec]',
|
||
'command = "echo hello"',
|
||
'extra_key = "preserved"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.exec));
|
||
assert.strictEqual(parsed.hooks.exec.length, 1);
|
||
// #2773: command and extra keys now live in [[hooks.exec.hooks]] sub-table
|
||
assert.ok(Array.isArray(parsed.hooks.exec[0].hooks), 'must emit [[hooks.exec.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.exec[0].hooks[0].command, 'echo hello');
|
||
assert.strictEqual(parsed.hooks.exec[0].hooks[0].type, 'command',
|
||
'migrated handler must carry type = "command" per Codex 0.124.0+ schema');
|
||
assert.strictEqual(parsed.hooks.exec[0].hooks[0].extra_key, 'preserved');
|
||
assert.equal(parsed.hooks.exec[0].event, undefined);
|
||
});
|
||
|
||
test('converts multiple [hooks.TYPE] sections to separate namespaced AoT blocks (#2760 CR5 finding 3)', () => {
|
||
const content = [
|
||
'[hooks.shell]',
|
||
'command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
'[hooks.exec]',
|
||
'command = "echo done"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell));
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.exec));
|
||
assert.strictEqual(parsed.hooks.shell.length, 1);
|
||
assert.strictEqual(parsed.hooks.exec.length, 1);
|
||
// #2773: commands now live in the [[hooks.<TYPE>.hooks]] sub-table
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command, 'node /home/.codex/hooks/gsd-check-update.js');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
|
||
'migrated shell handler must carry type = "command"');
|
||
assert.strictEqual(parsed.hooks.exec[0].hooks[0].command, 'echo done');
|
||
assert.strictEqual(parsed.hooks.exec[0].hooks[0].type, 'command',
|
||
'migrated exec handler must carry type = "command"');
|
||
});
|
||
|
||
test('migrates flat [[hooks]] with event=AfterCommand to [[hooks.AfterCommand]] namespaced form', () => {
|
||
// Flat [[hooks]] + event = "..." is incompatible with [[hooks.<EVENT>]] AoT in the same
|
||
// file — TOML cannot have hooks be both an array and a table. Migration promotes it.
|
||
const content = [
|
||
'[[hooks]]',
|
||
'event = "AfterCommand"',
|
||
'command = "echo custom"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.AfterCommand),
|
||
'flat [[hooks]] event=AfterCommand must become [[hooks.AfterCommand]] AoT');
|
||
assert.strictEqual(parsed.hooks.AfterCommand.length, 1);
|
||
assert.ok(Array.isArray(parsed.hooks.AfterCommand[0].hooks),
|
||
'must emit [[hooks.AfterCommand.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].command, 'echo custom');
|
||
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].type, 'command',
|
||
'migrated AfterCommand handler must carry type = "command" per Codex 0.124.0+ schema');
|
||
assert.equal(parsed.hooks.AfterCommand[0].event, undefined,
|
||
'event key consumed as namespace — must not appear in emitted block');
|
||
assert.ok(!Array.isArray(parsed.hooks), 'hooks must be a table, not a flat array');
|
||
});
|
||
|
||
test('end-to-end: install on config with old [hooks] map format produces namespaced AoT (#2637, #2760 CR5)', () => {
|
||
// Simulates the exact old GSD config.toml format that broke on Codex 0.124.0
|
||
const oldContent = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[hooks]',
|
||
'',
|
||
' [hooks.shell]',
|
||
' command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(oldContent);
|
||
const parsed = parseTomlToObject(result);
|
||
// Codex 0.124.0+: must produce array-of-tables form. CR5 finding 3:
|
||
// namespaced AoT [[hooks.shell]] (no flat [[hooks]] with synthetic event).
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell),
|
||
'hooks.shell must be array-of-tables in namespaced form');
|
||
assert.strictEqual(parsed.hooks.shell.length, 1);
|
||
// #2773: command lives in [[hooks.shell.hooks]] sub-table
|
||
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command,
|
||
'node /home/.codex/hooks/gsd-check-update.js');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
|
||
'migrated shell handler must carry type = "command" per Codex 0.124.0+ schema');
|
||
assert.equal(parsed.features && parsed.features.codex_hooks, true);
|
||
});
|
||
|
||
test('bare [hooks] section without sub-tables is dropped (no [[hooks]] block added)', () => {
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[hooks]',
|
||
'# no sub-tables, just an empty container',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
assert.ok(!result.match(/^\[hooks\]$/m), 'removes bare [hooks] section');
|
||
assert.ok(!result.includes('[[hooks]]'), 'no [[hooks]] added for bare [hooks] with no sub-tables');
|
||
assert.ok(result.includes('[features]'), 'preserves [features]');
|
||
assert.ok(result.includes('[model]'), 'preserves [model]');
|
||
});
|
||
|
||
test('upgrades stale [[hooks.SessionStart]] with event-level command to nested schema (#2773 CR6)', () => {
|
||
// Pre-#2773 single-block format: handler fields live directly under
|
||
// [[hooks.SessionStart]] rather than under [[hooks.SessionStart.hooks]].
|
||
// Codex 0.124.0+ rejects this shape. Migration must promote it.
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[[hooks.SessionStart]]',
|
||
'command = "echo stale-user-hook"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'stale [[hooks.SessionStart]] must remain a namespaced AoT');
|
||
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
|
||
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
|
||
'must emit [[hooks.SessionStart.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo stale-user-hook');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command',
|
||
'must inject type = "command" when source body has no explicit type');
|
||
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
|
||
'command must not remain at event-entry level after promotion');
|
||
assert.equal(parsed.features && parsed.features.codex_hooks, true);
|
||
});
|
||
|
||
test('leaves [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] untouched (already nested)', () => {
|
||
// Properly-nested schema: handler lives under [[hooks.SessionStart.hooks]].
|
||
// Migration must NOT create a double-wrapped [[hooks.SessionStart.hooks.hooks]] shape.
|
||
const content = [
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "echo already-nested"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(Array.isArray(parsed.hooks?.SessionStart),
|
||
'SessionStart must remain a namespaced AoT after no-op migration');
|
||
assert.strictEqual(parsed.hooks.SessionStart.length, 1,
|
||
'must not duplicate the event entry');
|
||
assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks),
|
||
'nested [[hooks.SessionStart.hooks]] sub-table must still be present');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks.length, 1,
|
||
'must not create a double-wrapped [[hooks.SessionStart.hooks.hooks]]');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo already-nested');
|
||
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
|
||
'command must not appear at event-entry level');
|
||
});
|
||
|
||
test('promotes multiple stale [[hooks.TYPE]] entries from different event types', () => {
|
||
const content = [
|
||
'[[hooks.SessionStart]]',
|
||
'command = "echo session"',
|
||
'',
|
||
'[[hooks.AfterCommand]]',
|
||
'command = "echo after-cmd"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart));
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.AfterCommand));
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].command, 'echo session');
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command');
|
||
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].command, 'echo after-cmd');
|
||
assert.strictEqual(parsed.hooks.AfterCommand[0].hooks[0].type, 'command');
|
||
assert.equal(parsed.hooks.SessionStart[0].command, undefined);
|
||
assert.equal(parsed.hooks.AfterCommand[0].command, undefined);
|
||
});
|
||
|
||
test('matcher-only [[hooks.SessionStart]] (no handler fields) is left untouched', () => {
|
||
// A [[hooks.SessionStart]] entry with only a `matcher` key is a valid
|
||
// event filter — no handler fields → not a stale single-block entry.
|
||
const content = [
|
||
'[[hooks.SessionStart]]',
|
||
'matcher = "some-tool"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(Array.isArray(parsed.hooks?.SessionStart),
|
||
'matcher-only SessionStart must remain a namespaced AoT');
|
||
assert.strictEqual(parsed.hooks.SessionStart.length, 1);
|
||
assert.strictEqual(parsed.hooks.SessionStart[0].matcher, 'some-tool',
|
||
'matcher key must be preserved');
|
||
assert.equal(parsed.hooks.SessionStart[0].hooks, undefined,
|
||
'matcher-only entry must not gain a .hooks sub-array');
|
||
assert.equal(parsed.hooks.SessionStart[0].command, undefined,
|
||
'no spurious command key must appear');
|
||
});
|
||
|
||
test('quoted event name with dot ([[hooks."before.tool"]]) is treated as single 2-segment namespace', () => {
|
||
// Regression for the split('.') bug: "before.tool" contains a dot, but the
|
||
// key is quoted so it is ONE segment — [[hooks."before.tool"]] has exactly
|
||
// two path segments and must be classified the same as [[hooks.SessionStart]].
|
||
// It should NOT be treated as a 3-level path (hooks / before / tool).
|
||
const content = [
|
||
'[[hooks."before.tool"]]',
|
||
'command = "echo hi"',
|
||
'',
|
||
].join('\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
const parsed = parseTomlToObject(result);
|
||
// The key in the parsed object is the unquoted event name "before.tool".
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks['before.tool']),
|
||
'[[hooks."before.tool"]] must be a namespaced AoT — not split on the inner dot'
|
||
);
|
||
assert.ok(
|
||
Array.isArray(parsed.hooks['before.tool'][0].hooks),
|
||
'must emit [[hooks."before.tool".hooks]] sub-table'
|
||
);
|
||
assert.strictEqual(
|
||
parsed.hooks['before.tool'][0].hooks[0].command,
|
||
'echo hi',
|
||
'command must be preserved in the nested handler sub-table'
|
||
);
|
||
// Ensure no spurious "before" or "tool" top-level hook keys appeared.
|
||
assert.equal(parsed.hooks?.before, undefined, 'must not split quoted key on dot');
|
||
});
|
||
|
||
test('CRLF line endings are preserved through migration (#2760 CR5: namespaced AoT)', () => {
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[hooks.shell]',
|
||
'command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\r\n');
|
||
const result = migrateCodexHooksMapFormat(content);
|
||
assert.ok(result.includes('[[hooks.shell]]\r\n'),
|
||
'uses CRLF in namespaced [[hooks.shell]] header');
|
||
// Round-trip parse confirms the structural shape independent of EOL.
|
||
const parsed = parseTomlToObject(result);
|
||
assert.ok(parsed.hooks && Array.isArray(parsed.hooks.shell));
|
||
// #2773: command lives in [[hooks.shell.hooks]] sub-table
|
||
assert.ok(Array.isArray(parsed.hooks.shell[0].hooks), 'must emit [[hooks.shell.hooks]] sub-table');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].command,
|
||
'node /home/.codex/hooks/gsd-check-update.js');
|
||
assert.strictEqual(parsed.hooks.shell[0].hooks[0].type, 'command',
|
||
'migrated shell handler must carry type = "command" per Codex 0.124.0+ schema');
|
||
});
|
||
});
|
||
|
||
// ─── shape parity between migration and managed emit (#2760 CR5 finding 3) ──
|
||
|
||
describe('Codex hooks emit: migration produces namespaced AoT so managed-emit converges', () => {
|
||
// After #2760 CR5 finding 3, the legacy migration path
|
||
// (migrateCodexHooksMapFormat) emits `[[hooks.<TYPE>]]` directly — the
|
||
// namespace IS the event, no synthetic `event = ...` field. The managed
|
||
// install path (writes "# GSD Hooks") detects existing namespaced AoT via
|
||
// hasUserNamespacedAotHooks and emits its block in the same shape. The two
|
||
// paths must therefore both produce a namespaced layout when a legacy
|
||
// [hooks.SessionStart] is migrated, eliminating the mixed flat+namespaced
|
||
// bug class entirely.
|
||
|
||
let tmpDir;
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-fieldparity-'));
|
||
});
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('migration of legacy [hooks.SessionStart] produces two-level nested AoT (#2773)', () => {
|
||
const legacyContent = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[hooks.SessionStart]',
|
||
'command = "node /home/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const migrated = migrateCodexHooksMapFormat(legacyContent);
|
||
const parsed = parseTomlToObject(migrated);
|
||
// Outer event entry
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'migration must emit [[hooks.SessionStart]] namespaced AoT'
|
||
);
|
||
assert.equal(parsed.hooks.SessionStart[0].event, undefined,
|
||
'migration must NOT emit a synthetic event field — namespace IS the event');
|
||
assert.equal(Array.isArray(parsed.hooks), false,
|
||
'migration must NOT emit a flat top-level [[hooks]] AoT');
|
||
// Inner handler sub-table
|
||
assert.ok(
|
||
Array.isArray(parsed.hooks.SessionStart[0].hooks),
|
||
'migration must emit [[hooks.SessionStart.hooks]] sub-table'
|
||
);
|
||
const handler = parsed.hooks.SessionStart[0].hooks[0];
|
||
assert.strictEqual(handler.type, 'command',
|
||
'migration must inject type = "command" in handler sub-table');
|
||
assert.strictEqual(
|
||
handler.command,
|
||
'node /home/.codex/hooks/gsd-check-update.js',
|
||
'migration must preserve original command value in handler sub-table'
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── mergeCodexConfig ───────────────────────────────────────────────────────────
|
||
|
||
describe('mergeCodexConfig', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-merge-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
const sampleBlock = generateCodexConfigBlock([
|
||
{ name: 'gsd-executor', description: 'Executes plans' },
|
||
]);
|
||
|
||
test('case 1: creates new config.toml', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
assert.ok(fs.existsSync(configPath), 'file created');
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'has marker');
|
||
// #2406: config.toml never gets an [agents.gsd-*] role table — the
|
||
// standalone agents/<name>.toml written by installCodexConfig is the
|
||
// sole canonical registration Codex auto-discovers.
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
assert.ok(!content.includes('[features]'), 'no features section');
|
||
assert.ok(!content.includes('multi_agent'), 'no multi_agent');
|
||
});
|
||
|
||
test('case 2: replaces existing GSD block', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const userContent = '[model]\nname = "o3"\n';
|
||
fs.writeFileSync(configPath, userContent + '\n' + sampleBlock + '\n');
|
||
|
||
// Re-merge with updated block
|
||
const newBlock = generateCodexConfigBlock([
|
||
{ name: 'gsd-executor', description: 'Updated description' },
|
||
{ name: 'gsd-planner', description: 'New agent' },
|
||
]);
|
||
mergeCodexConfig(configPath, newBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||
// #2406: description text is per-agent metadata carried only by the
|
||
// standalone TOML now — it no longer leaks into config.toml.
|
||
assert.ok(!content.includes('Updated description'), 'no per-agent description in config.toml');
|
||
assert.ok(!content.includes('[agents.gsd-planner]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
// Verify no duplicate markers
|
||
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
|
||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||
});
|
||
|
||
test('case 3: appends to config without GSD marker', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '[model]\nname = "o3"\n');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'adds marker');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
});
|
||
|
||
test('case 3 with existing [features]: preserves user features, does not inject GSD keys', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '[features]\nother_feature = true\n\n[model]\nname = "o3"\n');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves existing feature');
|
||
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
|
||
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'adds marker for agents block');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
});
|
||
|
||
test('case 3 strips existing [agents.gsd-*] sections before appending fresh block (#2406: fresh block never re-adds them)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const existing = [
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
'[agents.custom-agent]',
|
||
'description = "user agent"',
|
||
'',
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "old"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, existing);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
// The pre-existing legacy [agents.gsd-executor] role table is a leaked
|
||
// GSD section (stripLeakedGsdCodexSections) and the fresh block never
|
||
// re-adds one (#2406) — zero role tables should remain anywhere.
|
||
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
|
||
|
||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section');
|
||
assert.strictEqual(gsdStructCount, 0, 'legacy [agents.gsd-executor] struct entry is removed and not regrown');
|
||
assert.strictEqual(markerCount, 1, 'adds exactly one marker block');
|
||
assert.ok(!/\r?\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block');
|
||
});
|
||
|
||
test('idempotent: re-merge produces same result', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const first = fs.readFileSync(configPath, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const second = fs.readFileSync(configPath, 'utf8');
|
||
|
||
assert.strictEqual(first, second, 'idempotent merge');
|
||
});
|
||
|
||
test('case 2 after case 3 with existing [features]: no duplicate sections', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '[features]\nother_feature = true\n\n[model]\nname = "o3"\n');
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const featuresCount = (content.match(/^\[features\]\s*$/gm) || []).length;
|
||
assert.strictEqual(featuresCount, 1, 'exactly one [features] section');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves user feature keys');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
// Verify no duplicate markers
|
||
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
|
||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||
});
|
||
|
||
test('case 2 does not inject feature keys, and drops a legacy [agents.gsd-old] role table on reinstall (#2406)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const manualContent = '[features]\nother_feature = true\n\n' + GSD_CODEX_MARKER + '\n[agents.gsd-old]\ndescription = "old"\n';
|
||
fs.writeFileSync(configPath, manualContent);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
|
||
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves user feature');
|
||
// #2406: the pre-existing managed [agents.gsd-old] role table (below the
|
||
// marker, from a pre-fix install) is truncated away by Case 2's
|
||
// marker-truncate, and the fresh block never re-adds a role table.
|
||
assert.ok(!content.includes('[agents.gsd-old]'), 'legacy managed role table removed');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
});
|
||
|
||
test('#2406: update over a legacy 1.7.0-shape install removes duplicate [agents.gsd-*] role registrations, preserves unrelated user config, and is idempotent on rerun', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
// Simulate a config.toml produced by the pre-fix installer: a managed
|
||
// block with BOTH the bare [agents] dispatch-tuning table (holding the
|
||
// user's own max_threads = 4 alongside GSD's stale max_depth = 2) AND
|
||
// [agents.gsd-*] role tables duplicating what the standalone TOMLs
|
||
// already register — plus an unrelated user [model] table above the
|
||
// marker that must survive untouched.
|
||
const legacyInstall = [
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
GSD_CODEX_MARKER,
|
||
'',
|
||
'[agents]',
|
||
'max_threads = 4',
|
||
'max_depth = 2',
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "Executes plans"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
'[agents.gsd-planner]',
|
||
'description = "Creates plans"',
|
||
'config_file = "agents/gsd-planner.toml"',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, legacyInstall);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const first = fs.readFileSync(configPath, 'utf8');
|
||
|
||
// Legacy duplicate role registrations removed.
|
||
assert.strictEqual((first.match(/^\[agents\.gsd-/gm) || []).length, 0, 'zero [agents.gsd-*] role tables remain');
|
||
assert.ok(!first.includes('config_file'), 'no config_file line remains');
|
||
// Unrelated user config preserved.
|
||
assert.ok(first.includes('[model]') && first.includes('name = "o3"'), 'preserves unrelated user [model] table');
|
||
// User's own AgentsToml scalar tuning preserved; GSD's max_depth re-pinned to 1.
|
||
assert.ok(first.includes('max_threads = 4'), 'preserves user max_threads scalar');
|
||
assert.match(first, /max_depth = 1/, 're-pins GSD-managed max_depth to 1');
|
||
assert.doesNotMatch(first, /max_depth = 2/, 'stale legacy max_depth value is gone');
|
||
assert.strictEqual((first.match(/^\[agents\]\s*$/gm) || []).length, 1, 'exactly one [agents] table (no duplicate)');
|
||
assert.equal(validateCodexConfigSchema(first).ok, true, 'the migrated config still validates');
|
||
|
||
// Idempotent: running install/merge again produces byte-identical output — no regrowth.
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const second = fs.readFileSync(configPath, 'utf8');
|
||
assert.strictEqual(first, second, 'second merge is byte-identical — removed registrations do not regrow');
|
||
});
|
||
|
||
test('case 2 strips leaked [agents] and [agents.gsd-*] from before content, and does not regrow a role table (#2406)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const brokenContent = [
|
||
'[features]',
|
||
'child_agents_md = false',
|
||
'',
|
||
'[agents]',
|
||
'max_threads = 4',
|
||
'max_depth = 2',
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "old"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
GSD_CODEX_MARKER,
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "Executes plans"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, brokenContent);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
|
||
// #2406: neither the leaked pre-marker role table nor the legacy
|
||
// post-marker managed one survives — the fresh block emits none.
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table anywhere in the file');
|
||
// Verify the leaked [agents] table header above marker was stripped
|
||
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
|
||
const beforeMarker = content.substring(0, markerIndex);
|
||
assert.ok(!beforeMarker.match(/^\[agents\]\s*$/m), 'no leaked [agents] above marker');
|
||
assert.ok(!beforeMarker.includes('[agents.gsd-'), 'no leaked [agents.gsd-*] above marker');
|
||
});
|
||
|
||
test('case 2 strips leaked GSD-managed sections above marker in CRLF files, and does not regrow a role table (#2406)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const brokenContent = [
|
||
'[features]',
|
||
'child_agents_md = false',
|
||
'',
|
||
'[agents]',
|
||
'max_threads = 4',
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "stale"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
GSD_CODEX_MARKER,
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "Executes plans"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
].join('\r\n');
|
||
fs.writeFileSync(configPath, brokenContent, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
|
||
const beforeMarker = content.slice(0, markerIndex);
|
||
|
||
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
|
||
assert.strictEqual(countMatches(beforeMarker, /^\[agents\]\s*$/gm), 0, 'removes leaked [agents] above marker');
|
||
assert.strictEqual(countMatches(beforeMarker, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'removes leaked GSD agent section above marker');
|
||
// #2406: the fresh block never emits a role table, so zero remain
|
||
// anywhere in the file — not just above the marker.
|
||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'zero role tables anywhere');
|
||
assert.strictEqual(countMatches(content, /name = "gsd-executor"/g), 0, 'no name = field in struct format');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
});
|
||
|
||
test('case 2 strips bare [agents] tables (invalid in current Codex schema, #2760) and removes leaked GSD sections in CRLF files, without regrowing a role table (#2406)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const brokenContent = [
|
||
'[features]',
|
||
'child_agents_md = false',
|
||
'',
|
||
'[agents]',
|
||
'default = "custom-agent"',
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "stale"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
GSD_CODEX_MARKER,
|
||
'',
|
||
'[agents.gsd-executor]',
|
||
'description = "Executes plans"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
].join('\r\n');
|
||
fs.writeFileSync(configPath, brokenContent, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
|
||
const beforeMarker = content.slice(0, markerIndex);
|
||
|
||
// Bare [agents] is invalid under Codex's current schema (rejected with
|
||
// "expected struct AgentsToml") so install-time stripping always purges
|
||
// it (#2760). User feature keys above the marker are preserved.
|
||
// Structural assertion: TOML-parse the pre-marker region and verify the
|
||
// bare [agents] block is fully gone — header AND body keys (e.g.,
|
||
// `default = "custom-agent"`). A header-only check would miss a
|
||
// partial-strip regression that leaves orphan body keys reparented to a
|
||
// sibling section.
|
||
const parsedBefore = parseTomlToObject(beforeMarker);
|
||
assert.equal(
|
||
parsedBefore.agents,
|
||
undefined,
|
||
'bare [agents] block fully purged including body keys (#2760)',
|
||
);
|
||
assert.ok(
|
||
parsedBefore.features && parsedBefore.features.child_agents_md === false,
|
||
'preserves user feature keys above marker',
|
||
);
|
||
// #2406: the fresh block never emits a role table, so zero remain
|
||
// anywhere in the file — not just above the marker.
|
||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'zero role tables anywhere');
|
||
assert.strictEqual(countMatches(content, /name = "gsd-executor"/g), 0, 'no name = field in struct format');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
});
|
||
|
||
test('case 2 idempotent after case 3 with existing [features]', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '[features]\nother_feature = true\n');
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const first = fs.readFileSync(configPath, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const second = fs.readFileSync(configPath, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const third = fs.readFileSync(configPath, 'utf8');
|
||
|
||
assert.strictEqual(first, second, 'idempotent after 2nd merge');
|
||
assert.strictEqual(second, third, 'idempotent after 3rd merge');
|
||
});
|
||
|
||
test('preserves CRLF when appending GSD block to existing config', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '[model]\r\nname = "o3"\r\n', 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves existing CRLF content');
|
||
assert.ok(content.includes(`${GSD_CODEX_MARKER}\r\n`), 'writes marker with CRLF');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
});
|
||
|
||
test('uses the first newline style when appending GSD block to mixed-EOL configs', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, '# first line wins\n[model]\r\nname = "o3"\r\n', 'utf8');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('# first line wins\n[model]\r\nname = "o3"'), 'preserves the existing mixed-EOL model content');
|
||
assert.ok(content.includes(`\n\n${GSD_CODEX_MARKER}\n`), 'writes the managed block using the first newline style');
|
||
});
|
||
|
||
// ─── #3610: top-level keys below the marker must not be captured by [agents] ──
|
||
//
|
||
// Since #2088 the managed block opens with a bare `[agents]` table header. On
|
||
// upgrade (marker present) the block is regenerated IN PLACE, so a top-level
|
||
// key that lived below the marker (e.g. Codex Computer Use's `notify`) would
|
||
// parse as an [agents] member — validateCodexConfigSchema correctly rejected
|
||
// the merged file and the install aborted mid-flight.
|
||
|
||
test('#3610: top-level keys below the marker are hoisted above the managed block and the merged file validates', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`${GSD_CODEX_MARKER}\n\nnotify = ["x", "turn-ended"]\n\n[features]\nhooks = true\n`,
|
||
);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `merged config must pass Codex schema validation: ${schema.reason || ''}`);
|
||
const notifyIdx = content.indexOf('notify = ');
|
||
const agentsIdx = content.indexOf('[agents]');
|
||
assert.ok(notifyIdx !== -1 && agentsIdx !== -1, 'both the key and the agents table must be present');
|
||
assert.ok(notifyIdx < agentsIdx, 'a surviving top-level key must precede the [agents] table header, not parse as its member');
|
||
assert.ok(content.includes('[features]'), 'user tables below the marker are preserved after the block');
|
||
});
|
||
|
||
test('#3610 boundary: fresh install (no marker) with a top-level key still validates unchanged', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, 'notify = ["x", "turn-ended"]\n\n[features]\nhooks = true\n');
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const schema = validateCodexConfigSchema(fs.readFileSync(configPath, 'utf8'));
|
||
assert.ok(schema.ok, `fresh-install merge must validate: ${schema.reason || ''}`);
|
||
});
|
||
|
||
test('#3610 boundary: key above the marker is untouched by the hoist', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, `notify = ["x"]\n\n${GSD_CODEX_MARKER}\n\n[features]\nhooks = true\n`);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `control merge must validate: ${schema.reason || ''}`);
|
||
assert.ok(content.indexOf('notify = ') < content.indexOf(GSD_CODEX_MARKER), 'the pre-marker key stays pre-marker');
|
||
});
|
||
|
||
test('#3610: a multiline top-level value below the marker hoists as one unit', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const multiline = 'notify = [\n "x",\n "turn-ended",\n]';
|
||
fs.writeFileSync(configPath, `${GSD_CODEX_MARKER}\n\n${multiline}\n\n[features]\nhooks = true\n`);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `multiline hoist must validate: ${schema.reason || ''}`);
|
||
const hoistedAt = content.indexOf(multiline);
|
||
assert.ok(hoistedAt !== -1, 'the multiline value must survive the hoist intact');
|
||
assert.ok(hoistedAt < content.indexOf('[agents]'), 'the whole multiline value lands above the table header');
|
||
});
|
||
|
||
test('#3610: hoisted keys land at FILE scope even when the pre-marker region ends inside a table', () => {
|
||
// The default real-world layout: user tables ABOVE the marker, a top-level
|
||
// key below it. Appending the key after the pre-marker tables would merely
|
||
// capture it into THOSE tables ([features].notify) — the same defect class,
|
||
// silent to validateCodexConfigSchema, which inspects only agents/hooks.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`[features]\nhooks = true\n\n${GSD_CODEX_MARKER}\n\nnotify = ["x", "turn-ended"]\n\n[profiles.fast]\nmodel = "gpt-5"\n`,
|
||
);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `merge must validate: ${schema.reason || ''}`);
|
||
const parsed = parseTomlToObject(content);
|
||
assert.ok(Array.isArray(parsed.notify), 'the surviving key must parse as a top-level array');
|
||
assert.ok(!parsed.features || !('notify' in parsed.features), 'the key must NOT be captured into the pre-marker [features] table');
|
||
assert.ok(content.indexOf('notify = ') < content.indexOf('[features]'), 'file scope means before the FIRST table header, not just above the GSD block');
|
||
});
|
||
|
||
test('#3610: a top-level multiline STRING containing a table-header lookalike hoists intact', () => {
|
||
// The record parser must not treat the [looks.like.a.header] line inside
|
||
// the """ string as a table header (startsInMultilineString) — the split
|
||
// must land after the whole value.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const value = 'banner = """\nnot a [table.header] line\n"""\n';
|
||
fs.writeFileSync(configPath, `${GSD_CODEX_MARKER}\n\n${value}\n[features]\nhooks = true\n`);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `multiline-string hoist must validate: ${schema.reason || ''}`);
|
||
const hoistedAt = content.indexOf(value.trim());
|
||
assert.ok(hoistedAt !== -1, 'the multiline string must survive intact');
|
||
assert.ok(hoistedAt < content.indexOf('[agents]'), 'the whole string value lands above the table header');
|
||
});
|
||
|
||
test('#3610: merging twice is idempotent (the first merge is a fixed point)', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`[features]\nhooks = true\n\n${GSD_CODEX_MARKER}\n\nnotify = ["x"]\n\n[profiles.fast]\nmodel = "gpt-5"\n`,
|
||
);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
const once = fs.readFileSync(configPath, 'utf8');
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), once, 'the second merge must not move anything');
|
||
});
|
||
|
||
test('#3610: CRLF config with a top-level key below the marker validates', () => {
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`${GSD_CODEX_MARKER}\r\n\r\nnotify = ["x", "turn-ended"]\r\n\r\n[features]\r\nhooks = true\r\n`,
|
||
);
|
||
|
||
mergeCodexConfig(configPath, sampleBlock);
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const schema = validateCodexConfigSchema(content);
|
||
assert.ok(schema.ok, `CRLF upgrade merge must validate: ${schema.reason || ''}`);
|
||
assert.ok(content.indexOf('notify = ') < content.indexOf('[agents]'), 'hoist holds under CRLF');
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => {
|
||
'use strict';
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
/**
|
||
* Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`,
|
||
* removing any user/Codex-CLI settings added after the GSD-managed marker block.
|
||
*
|
||
* Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content
|
||
* BEFORE the marker but unconditionally discarded everything from the marker to
|
||
* EOF, replacing it with a freshly generated GSD block. Since a fresh install
|
||
* writes the GSD block as the file's entire content, any settings the user or
|
||
* Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER
|
||
* the block, and every subsequent update wiped them.
|
||
*
|
||
* The fix preserves genuine trailing TOML by routing the post-marker region
|
||
* through the existing `stripLeakedGsdCodexSections` (which removes GSD's own
|
||
* managed/leaked sections while keeping user tables), then re-appending it after
|
||
* the regenerated GSD block — without regressing #2406's de-dup.
|
||
*
|
||
* Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md
|
||
*
|
||
* NOTE: this describe block covers trailing-content-after-the-marker preservation
|
||
* ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the GSD block) — a case the
|
||
* pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases
|
||
* write user content BEFORE the marker/block, not after). Verified non-duplicate
|
||
* against both the pre-existing target and the other three folded sources.
|
||
*/
|
||
|
||
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 os = require('node:os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const {
|
||
generateCodexConfigBlock,
|
||
mergeCodexConfig,
|
||
GSD_CODEX_MARKER,
|
||
} = require('../bin/install.js');
|
||
|
||
describe('mergeCodexConfig trailing-content preservation (#2940)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
/** A GSD block with one agent (the shape installCodexConfig passes). */
|
||
const block = () =>
|
||
generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]);
|
||
|
||
test('trailingUserModelSectionPreserved', () => {
|
||
// Row 1 (failing-first regression): a config with the GSD block FIRST, then a user
|
||
// [model] section after it (the real-world layout — fresh install fills the file,
|
||
// user settings land after). Re-merge must preserve [model] byte-for-byte.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const trailing = '[model]\nname = "gpt-5.4"\n';
|
||
// First write: GSD block + user content after it (no content before the marker).
|
||
fs.writeFileSync(configPath, block() + '\n' + trailing);
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge');
|
||
assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim');
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present');
|
||
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
|
||
assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)');
|
||
assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated');
|
||
});
|
||
|
||
test('multipleTrailingTablesPreserved', () => {
|
||
// Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]).
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const trailing = [
|
||
'[mcp_servers.figma]',
|
||
'command = "npx"',
|
||
'args = ["-y", "figma-mcp"]',
|
||
'',
|
||
'[profiles.dev]',
|
||
'model = "o3"',
|
||
'sandbox_mode = "workspace-write"',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, block() + '\n' + trailing + '\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved');
|
||
assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved');
|
||
assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved');
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated');
|
||
});
|
||
|
||
test('reMergeIsIdempotent', () => {
|
||
// Row 3 (acceptance #2): merging the result of a merge again yields identical content.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
const afterFirst = fs.readFileSync(configPath, 'utf8');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
const afterSecond = fs.readFileSync(configPath, 'utf8');
|
||
|
||
assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)');
|
||
});
|
||
|
||
test('leakedGsdSectionAfterMarkerStillStripped', () => {
|
||
// Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the
|
||
// marker is still REMOVED (not regrown), while genuine user content after it is preserved.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const leakedAndUser = [
|
||
'[agents.gsd-executor]',
|
||
'description = "stale leaked"',
|
||
'config_file = "agents/gsd-executor.toml"',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||
assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)');
|
||
assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved');
|
||
});
|
||
|
||
test('bareAgentsAfterMarkerHandled', () => {
|
||
// Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents]
|
||
// block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a
|
||
// separate trailing [model] section. The fix must preserve the user scalar via the existing
|
||
// spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region
|
||
// logic, while regenerating exactly one managed [agents] table.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
// Simulate: fresh install wrote the GSD block; the user then added max_threads into the
|
||
// [agents] table and added a [model] section after it.
|
||
const existing = [
|
||
GSD_CODEX_MARKER,
|
||
'',
|
||
'[agents]',
|
||
'max_depth = 1',
|
||
'max_threads = 4',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
].join('\n');
|
||
fs.writeFileSync(configPath, existing + '\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
// The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]);
|
||
// there is exactly one [agents] table (the managed one).
|
||
assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block');
|
||
const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length;
|
||
assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)');
|
||
assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present');
|
||
assert.ok(content.includes('[model]'), 'trailing [model] still preserved');
|
||
});
|
||
|
||
test('beforeAndAfterMarkerBothPreserved', () => {
|
||
// Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
const before = '[profiles.work]\nmodel = "gpt-5.4"\n';
|
||
const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n';
|
||
fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(content.includes('[profiles.work]'), 'content before marker preserved');
|
||
assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved');
|
||
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
|
||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||
});
|
||
|
||
test('noTrailingContentUnchanged', () => {
|
||
// Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case)
|
||
// re-merges to just the regenerated block — no spurious blank-line artifacts introduced
|
||
// by the trailing-preservation logic.
|
||
const configPath = path.join(tmpDir, 'config.toml');
|
||
fs.writeFileSync(configPath, block() + '\n');
|
||
|
||
mergeCodexConfig(configPath, block());
|
||
|
||
const content = fs.readFileSync(configPath, 'utf8');
|
||
// No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe
|
||
// pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings.
|
||
assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file');
|
||
assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ─── Integration: installCodexConfig ────────────────────────────────────────────
|
||
|
||
describe('installCodexConfig (integration)', () => {
|
||
let tmpTarget;
|
||
const agentsSrc = path.join(__dirname, '..', 'agents');
|
||
|
||
beforeEach(() => {
|
||
tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-install-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpTarget);
|
||
});
|
||
|
||
// Only run if agents/ directory exists (not in CI without full checkout)
|
||
const hasAgents = fs.existsSync(agentsSrc);
|
||
|
||
(hasAgents ? test : test.skip)('generates config.toml and agent .toml files', () => {
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
const count = installCodexConfig(tmpTarget, agentsSrc);
|
||
|
||
assert.ok(count >= 11, `installed ${count} agents (expected >= 11)`);
|
||
|
||
// Verify config.toml
|
||
const configPath = path.join(tmpTarget, 'config.toml');
|
||
assert.ok(fs.existsSync(configPath), 'config.toml exists');
|
||
const config = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(config.includes(GSD_CODEX_MARKER), 'has GSD marker');
|
||
// #2406: config.toml must NOT register agent roles — the standalone
|
||
// agents/<name>.toml (verified below) is the sole canonical source
|
||
// Codex auto-discovers. A role table here would be a second,
|
||
// duplicate registration of the same role.
|
||
assert.ok(!config.includes('[agents.gsd-executor]'), 'no executor role table in config.toml');
|
||
assert.strictEqual((config.match(/^\[agents\.gsd-/gm) || []).length, 0, 'zero [agents.gsd-*] role tables of any kind');
|
||
assert.strictEqual((config.match(/^config_file = /gm) || []).length, 0, 'zero config_file lines');
|
||
assert.ok(!config.includes('multi_agent'), 'no feature flags');
|
||
|
||
// Verify per-agent .toml files
|
||
const agentsDir = path.join(tmpTarget, 'agents');
|
||
assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-executor.toml')), 'executor .toml exists');
|
||
assert.ok(fs.existsSync(path.join(agentsDir, 'gsd-plan-checker.toml')), 'plan-checker .toml exists');
|
||
|
||
const executorToml = fs.readFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'utf8');
|
||
assert.ok(executorToml.includes('name = "gsd-executor"'), 'executor has name');
|
||
assert.ok(executorToml.includes('description = "Executes GSD plans with atomic commits, deviation handling, checkpoint protocols, and state management. Spawned by execute-phase orchestrator or execute-plan command."'), 'executor has description');
|
||
assert.ok(executorToml.includes('sandbox_mode = "workspace-write"'), 'executor is workspace-write');
|
||
assert.ok(executorToml.includes('developer_instructions'), 'has developer_instructions');
|
||
|
||
const checkerToml = fs.readFileSync(path.join(agentsDir, 'gsd-plan-checker.toml'), 'utf8');
|
||
assert.ok(checkerToml.includes('name = "gsd-plan-checker"'), 'plan-checker has name');
|
||
assert.ok(checkerToml.includes('sandbox_mode = "read-only"'), 'plan-checker is read-only');
|
||
});
|
||
|
||
// PATHS-01: no ~/.claude references should leak into generated .toml files (#2320)
|
||
// Covers both trailing-slash and bare end-of-string forms, and scans all .toml
|
||
// files (agents/ subdirectory + top-level config.toml if present).
|
||
(hasAgents ? test : test.skip)('generated .toml files contain no leaked ~/.claude paths (PATHS-01)', () => {
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
installCodexConfig(tmpTarget, agentsSrc);
|
||
|
||
// Collect all .toml files: per-agent files in agents/ plus top-level config.toml.
|
||
// Not the shared listAgentFiles() helper: reads the INSTALLED target dir and
|
||
// collects generated .toml (absolute paths), not the source .md roster.
|
||
const agentsDir = path.join(tmpTarget, 'agents');
|
||
const tomlFiles = fs.readdirSync(agentsDir)
|
||
.filter(f => f.endsWith('.toml'))
|
||
.map(f => path.join(agentsDir, f));
|
||
const topLevel = path.join(tmpTarget, 'config.toml');
|
||
if (fs.existsSync(topLevel)) tomlFiles.push(topLevel);
|
||
assert.ok(tomlFiles.length > 0, 'at least one .toml file generated');
|
||
|
||
// Match ~/.claude, $HOME/.claude, or ./.claude with or without trailing slash
|
||
const leakPattern = /(?:~|\$HOME|\.)\/\.claude(?:\/|$)/;
|
||
const leaks = [];
|
||
for (const filePath of tomlFiles) {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
if (leakPattern.test(content)) {
|
||
leaks.push(path.relative(tmpTarget, filePath));
|
||
}
|
||
}
|
||
assert.deepStrictEqual(leaks, [], `No .toml files should contain .claude paths; found leaks in: ${leaks.join(', ')}`);
|
||
});
|
||
|
||
(hasAgents ? test : test.skip)('generated Codex agent .toml files do not call bare gsd-tools', () => {
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
installCodexConfig(tmpTarget, agentsSrc);
|
||
|
||
// Not the shared listAgentFiles() helper: reads the INSTALLED target dir and
|
||
// filters generated gsd-*.toml output, not the source .md roster.
|
||
const agentsDir = path.join(tmpTarget, 'agents');
|
||
const tomlFiles = fs.readdirSync(agentsDir)
|
||
.filter((file) => file.startsWith('gsd-') && file.endsWith('.toml'));
|
||
assert.ok(tomlFiles.length > 0, 'expected generated Codex agent toml files');
|
||
|
||
for (const file of tomlFiles) {
|
||
const content = fs.readFileSync(path.join(agentsDir, file), 'utf8');
|
||
assertNoCodexBareGsdToolsInvocation(content, `agents/${file}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => {
|
||
// allow-test-rule: structural-implementation-guard (#2834)
|
||
'use strict';
|
||
|
||
// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no
|
||
// model-routing fields because defaults.json (resolve_model_ids + runtime) was written
|
||
// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults
|
||
// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in
|
||
// the install source so a future edit can't silently re-introduce the gap.
|
||
//
|
||
// Verified non-duplicate: no existing coverage in this file asserts on
|
||
// writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and
|
||
// none of the other three folded sources touch this.
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||
|
||
test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => {
|
||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||
|
||
// Find the call to writeNonClaudeDefaults that precedes installCodexConfig.
|
||
const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); // allow-test-rule: structural-implementation-guard (#2834)
|
||
assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow');
|
||
|
||
// Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call.
|
||
const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); // allow-test-rule: structural-implementation-guard (#2834)
|
||
assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx,
|
||
'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' +
|
||
'(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)');
|
||
|
||
// The #2834 comment must be present at the call site.
|
||
const callSite = src.slice(writeIdx - 300, writeIdx + 100);
|
||
assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); // allow-test-rule: structural-implementation-guard (#2834)
|
||
});
|
||
|
||
test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => {
|
||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||
const fnIdx = src.indexOf('function writeNonClaudeDefaults('); // allow-test-rule: structural-implementation-guard (#2834)
|
||
assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function');
|
||
// Bound the slice by the next top-level declaration rather than a fixed
|
||
// character count, so adding a comment or a guard inside the function cannot
|
||
// push the asserted tokens out of the window and red this test spuriously.
|
||
const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); // allow-test-rule: structural-implementation-guard (#2834)
|
||
const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx);
|
||
// Source-text guard, not a behavioral call: writeNonClaudeDefaults() early-returns
|
||
// as a no-op whenever process.env.GSD_TEST_MODE is set (see its own body), and this
|
||
// suite sets GSD_TEST_MODE='1' file-wide (line 14), so invoking it here could never
|
||
// observe the resolve_model_ids/runtime writes it is supposed to make (#2834).
|
||
assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); // allow-test-rule: structural-implementation-guard (#2834)
|
||
assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); // allow-test-rule: structural-implementation-guard (#2834)
|
||
assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); // allow-test-rule: structural-implementation-guard (#2834)
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => {
|
||
/**
|
||
* Regression: issue #2639 — Codex install generated agent TOMLs with stale
|
||
* Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore).
|
||
*
|
||
* RCA: `installCodexConfig()` applied a narrow path-only regex pass before
|
||
* calling `generateCodexAgentToml()`, bypassing the full
|
||
* `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')`
|
||
* pipeline used on the .md emit path. Fix routes the TOML path through the
|
||
* same pipeline and extends the pipeline to cover bare `.claude/skills/`,
|
||
* `.claude/commands/`, `.claude/agents/`, and `.claudeignore`.
|
||
*
|
||
* Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers
|
||
* model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path
|
||
* neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite
|
||
* covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the
|
||
* installCodexConfig()-level TOML-emit pipeline this regression targets.
|
||
*/
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
|
||
const { installCodexConfig } = require('../bin/install.js');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
function makeTempDir() {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-'));
|
||
}
|
||
|
||
function writeAgentFixture(agentsSrc, name, body) {
|
||
const content = `---
|
||
name: ${name}
|
||
description: Test agent for #2639
|
||
---
|
||
|
||
${body}
|
||
`;
|
||
fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content);
|
||
}
|
||
|
||
describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => {
|
||
let tmpDir;
|
||
let agentsSrc;
|
||
let targetDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = makeTempDir();
|
||
agentsSrc = path.join(tmpDir, 'agents');
|
||
targetDir = path.join(tmpDir, 'codex');
|
||
fs.mkdirSync(agentsSrc, { recursive: true });
|
||
fs.mkdirSync(targetDir, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => {
|
||
writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [
|
||
'**Project instructions:** Read `./CLAUDE.md` if it exists.',
|
||
'',
|
||
'**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.',
|
||
'',
|
||
'**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.',
|
||
'',
|
||
'Also check `.claude/commands/` and `.claude/agents/` for definitions.',
|
||
'',
|
||
'DO respect .gitignore and .claudeignore. Do not review ignored files.',
|
||
'',
|
||
'Claude will refuse the task if policy violated.',
|
||
].join('\n'));
|
||
|
||
installCodexConfig(targetDir, agentsSrc);
|
||
|
||
const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml');
|
||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written');
|
||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||
|
||
assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML');
|
||
assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain');
|
||
assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain');
|
||
assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain');
|
||
assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain');
|
||
|
||
assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md');
|
||
assert.ok(
|
||
toml.includes('.codex/skills/') || toml.includes('.agents/skills/'),
|
||
'skills path neutralized'
|
||
);
|
||
|
||
// Standalone "Claude" agent-name references replaced
|
||
assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml),
|
||
'standalone Claude agent-name references replaced');
|
||
});
|
||
|
||
test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => {
|
||
writeAgentFixture(agentsSrc, 'gsd-executor', [
|
||
'This agent runs under Claude Code with the Claude Opus 4 model.',
|
||
'Do not confuse with Claude Sonnet or Claude Haiku.',
|
||
].join('\n'));
|
||
|
||
installCodexConfig(targetDir, agentsSrc);
|
||
const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8');
|
||
|
||
assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved');
|
||
assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ─── Codex config.toml [features] safety (#1202) ─────────────────────────────
|
||
|
||
describe('codex features section safety', () => {
|
||
test('non-boolean keys under [features] are moved to top level', () => {
|
||
// Simulate the bug from #1202: model = "gpt-5.4" under [features]
|
||
// causes "invalid type: string, expected a boolean in features"
|
||
const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.gsd-executor]\ndescription = "test"\n`;
|
||
|
||
const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||
assert.ok(featuresMatch, 'features section found');
|
||
|
||
const featuresBody = featuresMatch[1];
|
||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/))
|
||
.map(line => line.trim());
|
||
|
||
assert.strictEqual(nonBooleanKeys.length, 2, 'should detect 2 non-boolean keys');
|
||
assert.ok(nonBooleanKeys.includes('model = "gpt-5.4"'), 'detects model key');
|
||
assert.ok(nonBooleanKeys.includes('model_reasoning_effort = "medium"'), 'detects model_reasoning_effort key');
|
||
});
|
||
|
||
test('boolean keys under [features] are NOT flagged', () => {
|
||
const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`;
|
||
|
||
const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||
const featuresBody = featuresMatch[1];
|
||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/))
|
||
.map(line => line.trim());
|
||
|
||
assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys in a clean config');
|
||
});
|
||
});
|
||
|
||
describe('Codex install hook configuration (e2e)', () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-e2e-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('Codex install copies hook file that is referenced in hooks.json (#2153)', () => {
|
||
// Regression test: Codex install writes gsd-check-update hook reference into
|
||
// hooks.json and must also copy the hook file to ~/$CODEX_HOME/hooks/
|
||
runCodexInstall(codexHome);
|
||
|
||
const configContent = readCodexConfig(codexHome);
|
||
const parsedConfig = parseTomlToObject(configContent);
|
||
assert.ok(
|
||
!parsedConfig.hooks || !Array.isArray(parsedConfig.hooks.SessionStart),
|
||
'config.toml does not carry managed SessionStart hooks'
|
||
);
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
assert.equal(
|
||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||
true,
|
||
'hooks.json references gsd-check-update (.js on POSIX, .cmd on Windows)'
|
||
);
|
||
// The hook file must physically exist at the referenced path
|
||
const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js');
|
||
assert.ok(
|
||
fs.existsSync(hookFile),
|
||
`gsd-check-update.js must exist at ${hookFile} — hooks.json references it (directly on POSIX, via .cmd shim on Windows) but file was not installed`
|
||
);
|
||
});
|
||
|
||
test('fresh CODEX_HOME enables codex_hooks without draft root defaults', () => {
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks');
|
||
// #3017 / #3426: on POSIX the handler command uses the absolute Node binary path
|
||
// "<absolute-node-path>" "<hook-path.js>"
|
||
// On Windows (#3426) a .cmd shim is written instead; the command in hooks.json
|
||
// is the quoted .cmd path (no node runner prefix — cmd.exe executes .cmd natively).
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json');
|
||
if (process.platform === 'win32') {
|
||
// On Windows, the command is the .cmd shim path (quoted).
|
||
const expectedCmdPath = path.join(codexHome, 'hooks', 'gsd-check-update.cmd').replace(/\\/g, '/');
|
||
assert.strictEqual(gsdCommands[0], JSON.stringify(expectedCmdPath), 'win32: handler command must be the .cmd shim path (#3426)');
|
||
} else {
|
||
// On POSIX, the command is the node runner + .js hook path.
|
||
const expectedRunner = JSON.parse(resolveNodeRunner());
|
||
const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||
const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`;
|
||
assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)');
|
||
}
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key');
|
||
assertNoDraftRootKeys(content);
|
||
assertUsesOnlyEol(content, '\n');
|
||
});
|
||
|
||
test('#2406: config.toml carries no config_file entries — standalone agents/*.toml under CODEX_HOME are the sole canonical source', () => {
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
// config.toml previously carried a `config_file = "<absolute agents dir>/<name>.toml"`
|
||
// line per role, pointing back at the standalone TOML Codex already
|
||
// auto-discovers under $CODEX_HOME/agents/ — a second, duplicate
|
||
// registration of the same role that produced one
|
||
// "Ignoring malformed agent role definition: duplicate agent role name"
|
||
// warning per agent. That line is gone entirely now.
|
||
const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = '));
|
||
assert.deepStrictEqual(configFileLines, [], 'config.toml has zero config_file entries');
|
||
|
||
// The standalone per-agent TOMLs are still written under CODEX_HOME/agents/
|
||
// and are what Codex auto-discovers.
|
||
const agentsDir = path.join(codexHome, 'agents');
|
||
const tomlFiles = fs.existsSync(agentsDir)
|
||
? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml'))
|
||
: [];
|
||
assert.ok(tomlFiles.length > 0, 'standalone gsd-*.toml files exist under CODEX_HOME/agents/');
|
||
});
|
||
|
||
test('re-install repairs non-boolean keys trapped under [features] by previous install (#1379)', () => {
|
||
// Bug: a pre-#1346 install prepended [features] before bare top-level keys,
|
||
// trapping model= under [features]. Re-installing with the fix must detect
|
||
// and relocate those keys back to the top level so Codex can parse them.
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'model = "gpt-5.3-codex"',
|
||
'model_reasoning_effort = "high"',
|
||
'',
|
||
'[projects."/Users/oltmannk/myproject"]',
|
||
'trust_level = "trusted"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
|
||
// model= and model_reasoning_effort= must NOT be under [features]
|
||
const featuresIndex = content.indexOf('[features]');
|
||
const modelIndex = content.indexOf('model = "gpt-5.3-codex"');
|
||
const reasoningIndex = content.indexOf('model_reasoning_effort = "high"');
|
||
assert.ok(modelIndex !== -1, 'model key is present');
|
||
assert.ok(reasoningIndex !== -1, 'model_reasoning_effort key is present');
|
||
assert.ok(modelIndex < featuresIndex, 'model= relocated before [features]');
|
||
assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]');
|
||
|
||
// [features] should only contain boolean keys
|
||
const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||
assert.ok(featuresMatch, 'features section found');
|
||
const featuresBody = featuresMatch[1];
|
||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/));
|
||
assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]');
|
||
|
||
// User content preserved
|
||
assert.ok(content.includes('[projects."/Users/oltmannk/myproject"]'), 'preserves project section');
|
||
assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'one codex_hooks key');
|
||
});
|
||
|
||
test('existing LF config without [features] gets one features block and preserves user content', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'# user comment',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "echo custom"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key');
|
||
assert.ok(content.includes('# user comment'), 'preserves user comment');
|
||
assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section');
|
||
assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('bare top-level keys are NOT trapped under [features] (#1202)', () => {
|
||
// Real-world config: model= and model_reasoning_effort= at root level,
|
||
// followed by [projects] section. GSD must not prepend [features] before
|
||
// these keys, which would make Codex reject them as "expected a boolean".
|
||
writeCodexConfig(codexHome, [
|
||
'model = "gpt-5.4"',
|
||
'model_reasoning_effort = "high"',
|
||
'',
|
||
'[projects."/home/user/myproject"]',
|
||
'trust_level = "trusted"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
|
||
// [features] must come AFTER bare top-level keys
|
||
const featuresIndex = content.indexOf('[features]');
|
||
const modelIndex = content.indexOf('model = "gpt-5.4"');
|
||
const reasoningIndex = content.indexOf('model_reasoning_effort = "high"');
|
||
assert.ok(modelIndex < featuresIndex, 'model= stays before [features]');
|
||
assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]');
|
||
|
||
// [features] should only contain boolean keys
|
||
const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||
assert.ok(featuresMatch, 'features section found');
|
||
const featuresBody = featuresMatch[1];
|
||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/));
|
||
assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]');
|
||
|
||
// User content preserved
|
||
assert.ok(content.includes('[projects."/home/user/myproject"]'), 'preserves project section');
|
||
assert.ok(content.includes('trust_level = "trusted"'), 'preserves project trust level');
|
||
});
|
||
|
||
test('existing CRLF config without [features] preserves CRLF and adds codex_hooks', () => {
|
||
writeCodexConfig(codexHome, '# user comment\r\n[model]\r\nname = "o3"\r\n');
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key');
|
||
assert.ok(content.includes('# user comment'), 'preserves user comment');
|
||
assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves model section');
|
||
// [features] should be inserted between top-level lines and [model], not prepended
|
||
const featuresIndex = content.indexOf('[features]');
|
||
const modelIndex = content.indexOf('[model]');
|
||
assert.ok(featuresIndex < modelIndex, '[features] comes before [model]');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing CRLF [features] comment-only table gets codex_hooks without losing adjacent text', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'# user comment',
|
||
'[features]',
|
||
'# keep me',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\r\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||
assert.ok(content.includes('[features]\r\n# keep me\r\n\r\nhooks = true\r\n'), 'adds codex_hooks within comment-only table');
|
||
assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing [features] with trailing comment gets one codex_hooks without a second table', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features] # keep comment',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||
assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table');
|
||
assert.ok(content.indexOf('hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table');
|
||
assert.ok(content.indexOf('hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing [features] at EOF without trailing newline is updated in place', () => {
|
||
writeCodexConfig(codexHome, '[model]\nname = "o3"\n\n[features]');
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||
assert.ok(content.indexOf('hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header');
|
||
// In this EOF-without-trailing-newline edge case, the pre-existing
|
||
// [features] header has no blank-line boundary to close it, so the
|
||
// appended GSD marker/ownership comment textually falls *inside* what
|
||
// reads as the [features] section body, and `hooks = true` is inserted
|
||
// at the end of that body — after the marker, not before it. That
|
||
// ordering is unrelated to #2406 (verified unchanged against
|
||
// origin/next's install.js) and #2406 removed the [agents.<name>] role
|
||
// tables that used to anchor this assertion, so anchor on the bare
|
||
// [agents] dispatch-tuning table instead — codex_hooks always lands
|
||
// before it.
|
||
assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents]'), 'keeps codex_hooks before the [agents] dispatch-tuning table');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing empty [features] and codex_hooks = false are normalized and remain idempotent', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = false',
|
||
'other_feature = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "echo custom"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one codex_hooks = true');
|
||
assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'does not duplicate GSD update hook in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('quoted codex_hooks keys inside [features] are normalized without adding a bare duplicate', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'"codex_hooks" = false',
|
||
'other_feature = true',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted key to true');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 0, 'does not append a bare duplicate codex_hooks key');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('quoted [features] headers are recognized as the existing features table', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'["features"]',
|
||
'"codex_hooks" = false',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[(?:"features"|'features'|features)\]\s*$/gm), 1, 'keeps one features table');
|
||
assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true');
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('quoted table headers containing # are parsed without treating # as a comment start', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features."a#b"]',
|
||
'enabled = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table');
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing dotted features config stays dotted and does not grow a [features] table', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features.other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table');
|
||
assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key');
|
||
assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('root inline-table features assignments are left untouched without appending invalid dotted keys or hooks', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features = { other_feature = true }',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.ok(content.includes('features = { other_feature = true }'), 'preserves the root inline-table assignment');
|
||
assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key');
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table');
|
||
assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely');
|
||
// #2406: config.toml no longer carries an [agents.<name>] role table —
|
||
// it still installs the managed [agents] dispatch-tuning block.
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('root scalar features assignments are left untouched without appending invalid dotted keys or hooks', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features = "disabled"',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.ok(content.includes('features = "disabled"'), 'preserves the root scalar assignment');
|
||
assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key');
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table');
|
||
assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely');
|
||
// #2406: config.toml no longer carries an [agents.<name>] role table —
|
||
// it still installs the managed [agents] dispatch-tuning block.
|
||
assert.ok(content.includes(GSD_CODEX_MARKER), 'still installs the managed GSD block');
|
||
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('quoted dotted codex_hooks keys stay dotted and are normalized without duplication', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features."codex_hooks" = false',
|
||
'features.other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table');
|
||
assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true');
|
||
assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate');
|
||
assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('multiline dotted features assignments insert codex_hooks after the full assignment block', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features.notes = """',
|
||
'keep-me',
|
||
'"""',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment');
|
||
assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key');
|
||
assert.ok(content.indexOf('features.hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes');
|
||
assert.ok(content.indexOf('features.hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing empty [features] table is populated with one codex_hooks key', () => {
|
||
writeCodexConfig(codexHome, '[features]\r\n\r\n[model]\r\nname = "o3"\r\n');
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||
assert.ok(content.includes('[features]\r\n\r\nhooks = true\r\n'), 'adds codex_hooks to empty table');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('multiline strings inside [features] do not create fake tables or fake codex_hooks matches', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'notes = \'\'\'',
|
||
'[model]',
|
||
'codex_hooks = false',
|
||
'\'\'\'',
|
||
'other_feature = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "AfterCommand"',
|
||
'command = "echo custom-after-command"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds a real codex_hooks key once');
|
||
assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string');
|
||
assert.ok(content.indexOf('hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content');
|
||
// Parse structurally — verify codex_hooks and migrated AfterCommand hook via parsed object
|
||
const parsed = parseTomlToObject(content);
|
||
assert.equal(parsed.features?.hooks, true, 'writes a real hooks boolean key (#3566)');
|
||
assert.ok(Array.isArray(parsed.hooks?.AfterCommand), 'AfterCommand flat [[hooks]] migrated to namespaced AoT');
|
||
const afterCmds = parsed.hooks.AfterCommand.flatMap((entry) =>
|
||
Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command).filter(Boolean) : []
|
||
);
|
||
assert.ok(afterCmds.includes('echo custom-after-command'), 'preserves AfterCommand user hook command');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('non-boolean codex_hooks assignments are normalized to true without duplication', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = "sometimes"',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'normalizes to one true value');
|
||
assert.ok(!content.includes('codex_hooks = "sometimes"'), 'removes non-boolean value');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('multiline basic-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = """',
|
||
'multiline-basic-sentinel',
|
||
'still-in-string',
|
||
'"""',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value');
|
||
assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves following feature keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('multiline literal-string codex_hooks assignments are fully normalized without leaving trailing lines behind', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = \'\'\'',
|
||
'multiline-literal-sentinel',
|
||
'still-in-literal',
|
||
'\'\'\'',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value');
|
||
assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves following feature keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('multiline array codex_hooks assignments are fully normalized without leaving trailing lines behind', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = [',
|
||
' "array-sentinel-1",',
|
||
' "array-sentinel-2",',
|
||
']',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline array assignment with one true value');
|
||
assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines');
|
||
assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves following feature keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('triple-quoted codex_hooks values keep inline comments when normalized', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = """sometimes""" # keep me',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'normalizes to true and preserves inline comment');
|
||
assert.ok(!content.includes('"""sometimes"""'), 'removes the old triple-quoted value');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('existing CRLF codex_hooks = true stays single and preserves non-GSD hooks', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'other_feature = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "AfterCommand"',
|
||
'command = "echo custom-after-command"',
|
||
'',
|
||
].join('\r\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json');
|
||
assertUsesOnlyEol(content, '\r\n');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('codex_hooks = true with an inline comment is treated as enabled for hook installation', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = true # keep me',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||
assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value');
|
||
assert.ok(content.includes('other_feature = true'), 'preserves other feature keys');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'adds the GSD update hook once in hooks.json');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
|
||
test('mixed-EOL configs use the first newline style for inserted Codex content', () => {
|
||
writeCodexConfig(codexHome, '# first line wins\n[model]\r\nname = "o3"\r\n');
|
||
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome);
|
||
|
||
const content = readCodexConfig(codexHome);
|
||
// [features] is inserted after top-level lines, before [model] — not prepended
|
||
assert.ok(content.includes('# first line wins\n\n[features]\nhooks = true\n'), 'inserts features after top-level lines using first newline style');
|
||
assert.ok(content.includes(`# GSD Agent Configuration — managed by gsd-core installer\n`), 'writes the managed agent block using the first newline style');
|
||
// Structural check: managed SessionStart hooks live in hooks.json.
|
||
const parsedMixed = parseTomlToObject(content);
|
||
assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml');
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json');
|
||
assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines');
|
||
assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs');
|
||
assertNoDraftRootKeys(content);
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => {
|
||
// Regression tests for #2695 — Codex native updates omit the update-hook worker
|
||
// and the managed-hooks registry.
|
||
//
|
||
// The Codex install branch in bin/install.js used to allowlist only two of the
|
||
// four hook files the shipped build emitted at the time (gsd-check-update.js +
|
||
// gsd-context-monitor.js — the latter permanently removed by #2586, see below),
|
||
// and gated the entire branch on !isMinimalMode so the
|
||
// `core` profile installed none of them. The parent SessionStart hook spawn()s
|
||
// the worker, which require()s the registry — so Codex was wired to a dependency
|
||
// chain the same installer never delivered.
|
||
//
|
||
// These tests drive the real installer (bin/install.js) behaviorally into an
|
||
// isolated temp config dir and assert the complete three-file set is delivered
|
||
// for both profiles, the registry is byte-for-byte, the version stamps resolve
|
||
// to the installed package version, and unrelated user files are preserved.
|
||
//
|
||
// #2586 reduced the set back to three: gsd-context-monitor.js read a Claude-only
|
||
// statusline bridge file Codex never writes, so it was a guaranteed silent no-op
|
||
// on every Codex hook event and was dropped from CODEX_HOOKS_TO_COPY for good.
|
||
//
|
||
// Verified non-duplicate: the pre-existing 'Codex install hook configuration
|
||
// (e2e)' suite above only asserts gsd-check-update.js delivery/wiring — it never
|
||
// asserts on gsd-check-update-worker.js, managed-hooks-registry.cjs, the
|
||
// core/full profile matrix, upgrade-refresh, byte-for-byte registry copy,
|
||
// idempotency of the three-file set, user-file preservation, or the
|
||
// core-profile negative-space (no agent files) — all genuinely distinct
|
||
// assertions this fold adds.
|
||
|
||
'use strict';
|
||
|
||
const { test, describe, before } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
|
||
const { cleanup } = require('./helpers.cjs');
|
||
const {
|
||
INSTALL_SCRIPT,
|
||
BUILD_SCRIPT,
|
||
HOOKS_DIST,
|
||
installerEnv,
|
||
} = require('./helpers/install-shared.cjs');
|
||
|
||
const PKG_VERSION = require('../package.json').version;
|
||
|
||
// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs.
|
||
const {
|
||
BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS,
|
||
INSTALL_TIMEOUT_MS,
|
||
} = require('./helpers/timeouts.cjs');
|
||
|
||
// The three-file hook set the Codex surface must deliver together (#2695).
|
||
// gsd-context-monitor.js was removed from this set by #2586: it read a
|
||
// Claude-only statusline bridge file Codex never writes, so it was a
|
||
// guaranteed silent no-op on every Codex hook event.
|
||
const CODEX_HOOK_FILES = [
|
||
'gsd-check-update.js',
|
||
'gsd-check-update-worker.js',
|
||
'managed-hooks-registry.cjs',
|
||
];
|
||
|
||
// Build hooks/dist before any install runs (the installer copies from there).
|
||
before(() => {
|
||
const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS });
|
||
throwIfFailed(r, `node ${BUILD_SCRIPT}`);
|
||
});
|
||
|
||
function hooksDirOf(configDir) {
|
||
return path.join(configDir, 'hooks');
|
||
}
|
||
|
||
/** Run the Codex installer into an isolated temp config dir. */
|
||
function runCodexInstall({ profile, preseed }) {
|
||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`));
|
||
if (preseed) {
|
||
const hooksDest = hooksDirOf(configDir);
|
||
fs.mkdirSync(hooksDest, { recursive: true });
|
||
for (const [name, body] of Object.entries(preseed)) {
|
||
fs.writeFileSync(path.join(hooksDest, name), body);
|
||
}
|
||
}
|
||
// Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"`
|
||
// override resolves via os.homedir(); sandboxing keeps the spawn self-contained
|
||
// (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test).
|
||
const result = runNode(
|
||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`],
|
||
{ env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||
);
|
||
return { configDir, result };
|
||
}
|
||
|
||
// Older-version stamp used to pre-seed an "upgrade" scenario.
|
||
const OLDER_VERSION = '1.7.0';
|
||
|
||
describe('#2695: fresh Codex installs deliver the complete three-file hook set', () => {
|
||
for (const profile of ['core', 'full']) {
|
||
test(`fresh --profile=${profile} installs all three hook files`, (t) => {
|
||
const { configDir, result } = runCodexInstall({ profile });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const hooksDir = hooksDirOf(configDir);
|
||
for (const file of CODEX_HOOK_FILES) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(hooksDir, file)),
|
||
`expected ${file} under <config>/hooks for --profile=${profile}\n` +
|
||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('#2695: Codex upgrades refresh all three hook files to the current version', () => {
|
||
// Pre-seed all three files stamped at OLDER_VERSION so an upgrade must overwrite them.
|
||
function olderSeed() {
|
||
const seed = {};
|
||
for (const name of CODEX_HOOK_FILES) {
|
||
// Registry carries no version token; seed it with a stale sentinel body.
|
||
if (name.endsWith('.cjs')) {
|
||
seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`;
|
||
} else {
|
||
seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`;
|
||
}
|
||
}
|
||
return seed;
|
||
}
|
||
|
||
for (const profile of ['core', 'full']) {
|
||
test(`--profile=${profile} upgrade refreshes all three hook files`, (t) => {
|
||
const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const hooksDir = hooksDirOf(configDir);
|
||
// All three must now carry the current version stamp where one exists, and
|
||
// the registry must no longer be the stale sentinel.
|
||
for (const name of CODEX_HOOK_FILES) {
|
||
const dest = path.join(hooksDir, name);
|
||
assert.ok(
|
||
fs.existsSync(dest),
|
||
`expected refreshed ${name} for --profile=${profile}\n` +
|
||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||
);
|
||
}
|
||
// The registry must be REFRESHED on upgrade, not merely present: assert it no
|
||
// longer carries the stale sentinel and now matches the shipped dist byte-for-byte
|
||
// (the raw-copy fallback must overwrite an existing dest, not skip it).
|
||
const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs');
|
||
const registryBytes = fs.readFileSync(registryDest, 'utf8');
|
||
assert.ok(
|
||
!registryBytes.includes(`stale registry ${OLDER_VERSION}`),
|
||
`registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`,
|
||
);
|
||
assert.deepStrictEqual(
|
||
fs.readFileSync(registryDest),
|
||
fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')),
|
||
`refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`,
|
||
);
|
||
// Version stamps resolved (acceptance #2/#3).
|
||
const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js'));
|
||
assert.strictEqual(
|
||
workerStamp, PKG_VERSION,
|
||
`worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` +
|
||
`got "${workerStamp}" for --profile=${profile}`,
|
||
);
|
||
const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js'));
|
||
assert.strictEqual(
|
||
parentStamp, PKG_VERSION,
|
||
`parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` +
|
||
`got "${parentStamp}" for --profile=${profile}`,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => {
|
||
for (const profile of ['core', 'full']) {
|
||
test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => {
|
||
const { configDir, result } = runCodexInstall({ profile });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs');
|
||
assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`);
|
||
const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs'));
|
||
const destBytes = fs.readFileSync(dest);
|
||
assert.deepStrictEqual(
|
||
destBytes, distBytes,
|
||
`managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('#2695: worker hook-version stamp is a literal install-time value', () => {
|
||
test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => {
|
||
const { configDir } = runCodexInstall({ profile: 'full' });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js');
|
||
const content = fs.readFileSync(workerPath, 'utf8');
|
||
// The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape.
|
||
assert.ok(
|
||
!content.includes('{{GSD_VERSION}}'),
|
||
'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run',
|
||
);
|
||
// And the resolved value must be the literal version, present on the version-comment line.
|
||
const stamp = readHookVersionLine(workerPath);
|
||
assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`);
|
||
});
|
||
});
|
||
|
||
describe('#2695: unrelated user-owned hook files are preserved', () => {
|
||
for (const profile of ['core', 'full']) {
|
||
test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => {
|
||
const userOwned = 'my-custom-hook.js';
|
||
const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n';
|
||
const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const dest = path.join(hooksDirOf(configDir), userOwned);
|
||
assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`);
|
||
assert.strictEqual(
|
||
fs.readFileSync(dest, 'utf8'), userBody,
|
||
`user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('#2695: re-running the installer is idempotent for the three-file set', () => {
|
||
test('a second full install leaves all three files present and correctly stamped', (t) => {
|
||
const first = runCodexInstall({ profile: 'full' });
|
||
t.after(() => cleanup(first.configDir));
|
||
// Second run into the SAME config dir.
|
||
const result2 = runNode(
|
||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'],
|
||
{ env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||
);
|
||
assert.ok(result2.stdout || result2.stderr);
|
||
|
||
const hooksDir = hooksDirOf(first.configDir);
|
||
for (const name of CODEX_HOOK_FILES) {
|
||
assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`);
|
||
}
|
||
assert.strictEqual(
|
||
readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')),
|
||
PKG_VERSION,
|
||
'worker stamp must remain correct after a second install',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => {
|
||
// For the update-check/context-monitor hooks to actually fire, Codex needs both
|
||
// the feature flag in config.toml AND the hooks.json routing — copying inert
|
||
// files alone would leave `core` with scripts Codex never invokes. Entering the
|
||
// codex-toml branch for `core` (the #2695 gate change) synthesizes `[features]
|
||
// hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers
|
||
// the hooks. This is the intended behavior of the fix, not a side effect — these
|
||
// assertions pin it so a future re-gating cannot silently regress it.
|
||
test('--profile=core writes config.toml enabling the hooks feature', (t) => {
|
||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const configPath = path.join(configDir, 'config.toml');
|
||
assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled');
|
||
const config = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core');
|
||
});
|
||
|
||
test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => {
|
||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const hooksJsonPath = path.join(configDir, 'hooks.json');
|
||
assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json');
|
||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||
const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart');
|
||
// The command points at the gsd-check-update hook script. Its extension is
|
||
// platform-specific — Windows routes through a .cmd shim, POSIX through .js —
|
||
// so assert on the basename prefix, not a hardcoded extension (Windows parity).
|
||
const routedToUpdateHook = sessionStartCmds.some((c) => {
|
||
const token = c.replace(/"/g, '').replace(/\\/g, '/');
|
||
const segs = token.split('/');
|
||
const last = segs[segs.length - 1];
|
||
return last.startsWith('gsd-check-update.');
|
||
});
|
||
assert.ok(
|
||
routedToUpdateHook,
|
||
`core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('#2695: the core profile still installs no agent files (negative space)', () => {
|
||
test('--profile=core delivers hooks but no gsd-* agent files', (t) => {
|
||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||
t.after(() => cleanup(configDir));
|
||
|
||
// Hooks delivered (the fix)…
|
||
for (const name of CODEX_HOOK_FILES) {
|
||
assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`);
|
||
}
|
||
// …but the full agent surface is still absent (core stays minimal). Codex agents
|
||
// are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both
|
||
// extensions — a .md-only filter would miss a Codex agent-surface regression.
|
||
const agentsDir = path.join(configDir, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const gsdAgents = fs.readdirSync(agentsDir).filter(
|
||
(f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')),
|
||
);
|
||
assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface');
|
||
}
|
||
// And config.toml must carry no agent role sections.
|
||
const configPath = path.join(configDir, 'config.toml');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(
|
||
!/^\[agents\.gsd-/m.test(config),
|
||
'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)',
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Read the `// gsd-hook-version: <value>` comment value from a hook file.
|
||
* Returns the trimmed literal. Used so tests assert on the structured stamp,
|
||
* not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule).
|
||
*/
|
||
function readHookVersionLine(hookPath) {
|
||
const content = fs.readFileSync(hookPath, 'utf8');
|
||
const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
/**
|
||
* Collect every hook command string registered under a given Codex hooks.json
|
||
* event key. Used so the SessionStart-wiring test asserts on the structured
|
||
* hook entries (commands), not on raw text matching against the whole file.
|
||
*/
|
||
function collectHookCommands(hooksJson, eventName) {
|
||
const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName]))
|
||
? hooksJson.hooks[eventName]
|
||
: [];
|
||
return entries.flatMap((entry) =>
|
||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||
.map((h) => (h && typeof h.command === 'string' ? h.command : null))
|
||
.filter(Boolean),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-uninstall-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('fresh install removes the GSD-added codex_hooks feature on uninstall', () => {
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.strictEqual(cleaned, null, 'fresh GSD-only config strips back to nothing');
|
||
});
|
||
|
||
test('install then uninstall removes [features].codex_hooks while preserving other feature keys, comments, hooks, and CRLF', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'# keep me',
|
||
'other_feature = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "AfterCommand"',
|
||
'command = "echo custom-after-command"',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\r\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned, 'preserves user config after uninstall cleanup');
|
||
assert.strictEqual(countMatches(cleaned, /^\[features\](?:\s*#.*)?$/gm), 1, 'keeps the existing features table');
|
||
assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the GSD-added codex_hooks key');
|
||
assert.ok(cleaned.includes('# keep me'), 'preserves user comments in [features]');
|
||
assert.ok(cleaned.includes('other_feature = true'), 'preserves other feature keys');
|
||
assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes only the GSD update hook');
|
||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||
assertUsesOnlyEol(cleaned, '\r\n');
|
||
});
|
||
|
||
test('install then uninstall removes dotted features.codex_hooks without creating a [features] table', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features.other_feature = true',
|
||
'',
|
||
'[[hooks]]',
|
||
'event = "AfterCommand"',
|
||
'command = "echo custom-after-command"',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned.includes('features.other_feature = true'), 'preserves other dotted feature keys');
|
||
assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 0, 'removes the dotted GSD codex_hooks key');
|
||
assert.strictEqual(countMatches(cleaned, /^\[features\]\s*$/gm), 0, 'does not leave behind a [features] table');
|
||
assert.strictEqual(countMatches(cleaned, /echo custom-after-command/g), 1, 'preserves non-GSD hooks');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook');
|
||
});
|
||
|
||
test('install then uninstall preserves a pre-existing [features].codex_hooks = true', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned.includes('[features]\ncodex_hooks = true\nother_feature = true'), 'preserves the user-authored codex_hooks assignment');
|
||
assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 1, 'keeps the pre-existing codex_hooks key');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook');
|
||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||
});
|
||
|
||
test('install then uninstall preserves a pre-existing quoted [features]."codex_hooks" = true', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[features]',
|
||
'"codex_hooks" = true',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned.includes('[features]\n"codex_hooks" = true\nother_feature = true'), 'preserves the user-authored quoted codex_hooks assignment');
|
||
assert.strictEqual(countMatches(cleaned, /^"codex_hooks" = true$/gm), 1, 'keeps the pre-existing quoted codex_hooks key');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook');
|
||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||
});
|
||
|
||
test('install then uninstall preserves a pre-existing root dotted features.codex_hooks = true', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'features.codex_hooks = true',
|
||
'features.other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned.includes('features.codex_hooks = true\nfeatures.other_feature = true'), 'preserves the user-authored dotted codex_hooks assignment');
|
||
assert.strictEqual(countMatches(cleaned, /^features\.codex_hooks = true$/gm), 1, 'keeps the pre-existing dotted codex_hooks key');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook');
|
||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||
});
|
||
|
||
test('install then uninstall leaves short-circuited root features assignments untouched', () => {
|
||
const cases = [
|
||
'features = { other_feature = true }\n\n[model]\nname = "o3"\n',
|
||
'features = "disabled"\n\n[model]\nname = "o3"\n',
|
||
];
|
||
|
||
for (const initialContent of cases) {
|
||
writeCodexConfig(codexHome, initialContent);
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`);
|
||
|
||
cleanup(codexHome);
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
}
|
||
});
|
||
|
||
test('install then uninstall keeps mixed-EOL user content stable while removing GSD hook state', () => {
|
||
const initialContent = [
|
||
'# first line wins',
|
||
'[features]',
|
||
'other_feature = true',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n');
|
||
|
||
writeCodexConfig(codexHome, initialContent);
|
||
runCodexInstall(codexHome);
|
||
|
||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||
assert.ok(cleaned.includes('# first line wins\n[features]\r\nother_feature = true\r\n\r\n[model]\r\nname = "o3"'), 'preserves the original mixed-EOL user content');
|
||
assert.strictEqual(countMatches(cleaned, /^codex_hooks = true$/gm), 0, 'removes the injected codex_hooks key');
|
||
assert.strictEqual(countMatches(cleaned, /gsd-check-update\.js/g), 0, 'removes the GSD update hook');
|
||
assert.strictEqual(countMatches(cleaned, /\[agents\.gsd-/g), 0, 'removes managed GSD agent sections');
|
||
});
|
||
});
|
||
|
||
// ─── #1326: cleanupCodexSkillMetadataSidecars (replaces #774 writeCodexSkillMetadataFiles) ──
|
||
|
||
describe('cleanupCodexSkillMetadataSidecars (#1326)', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-sidecar-cleanup-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('Codex install does not emit managed agents/openai.yaml sidecars and removes stale ones (#1326)', () => {
|
||
// gsd-foo: managed skill with stale sidecar → sidecar removed, empty agents/ pruned
|
||
const fooAgents = path.join(tmpDir, 'gsd-foo', 'agents');
|
||
fs.mkdirSync(fooAgents, { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, 'gsd-foo', 'SKILL.md'), '---\nname: gsd-foo\n---\nBody.\n');
|
||
fs.writeFileSync(path.join(fooAgents, 'openai.yaml'), 'interface:\n display_name: "foo"\n');
|
||
|
||
// gsd-dev-preferences: user-owned → sidecar PRESERVED
|
||
const prefAgents = path.join(tmpDir, 'gsd-dev-preferences', 'agents');
|
||
fs.mkdirSync(prefAgents, { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, 'gsd-dev-preferences', 'SKILL.md'), '---\nname: gsd-dev-preferences\n---\nBody.\n');
|
||
const userYaml = 'interface:\n display_name: "my prefs"\n short_description: "User-authored"\n';
|
||
fs.writeFileSync(path.join(prefAgents, 'openai.yaml'), userYaml);
|
||
|
||
// gsd-bar: managed skill with sidecar + another file in agents/ → sidecar removed, agents/ kept (has other.txt)
|
||
const barAgents = path.join(tmpDir, 'gsd-bar', 'agents');
|
||
fs.mkdirSync(barAgents, { recursive: true });
|
||
fs.writeFileSync(path.join(tmpDir, 'gsd-bar', 'SKILL.md'), '---\nname: gsd-bar\n---\nBody.\n');
|
||
fs.writeFileSync(path.join(barAgents, 'openai.yaml'), 'interface:\n display_name: "bar"\n');
|
||
fs.writeFileSync(path.join(barAgents, 'other.txt'), 'some other content\n');
|
||
|
||
// helper: non-gsd dir with openai.yaml → UNTOUCHED
|
||
const helperAgents = path.join(tmpDir, 'helper', 'agents');
|
||
fs.mkdirSync(helperAgents, { recursive: true });
|
||
fs.writeFileSync(path.join(helperAgents, 'openai.yaml'), 'interface:\n display_name: "helper"\n');
|
||
|
||
cleanupCodexSkillMetadataSidecars(tmpDir);
|
||
|
||
// gsd-foo: sidecar removed and empty agents/ pruned
|
||
assert.ok(!fs.existsSync(path.join(fooAgents, 'openai.yaml')),
|
||
'gsd-foo/agents/openai.yaml must be removed (managed stale sidecar)');
|
||
assert.ok(!fs.existsSync(fooAgents),
|
||
'gsd-foo/agents/ must be pruned when empty after sidecar removal');
|
||
|
||
// gsd-dev-preferences: user-owned, sidecar preserved
|
||
assert.ok(fs.existsSync(path.join(prefAgents, 'openai.yaml')),
|
||
'gsd-dev-preferences/agents/openai.yaml must be preserved (user-owned)');
|
||
assert.strictEqual(fs.readFileSync(path.join(prefAgents, 'openai.yaml'), 'utf8'), userYaml,
|
||
'gsd-dev-preferences/agents/openai.yaml content must be unchanged');
|
||
|
||
// gsd-bar: sidecar removed but agents/ kept (still has other.txt)
|
||
assert.ok(!fs.existsSync(path.join(barAgents, 'openai.yaml')),
|
||
'gsd-bar/agents/openai.yaml must be removed');
|
||
assert.ok(fs.existsSync(barAgents),
|
||
'gsd-bar/agents/ must NOT be pruned (still contains other.txt)');
|
||
assert.ok(fs.existsSync(path.join(barAgents, 'other.txt')),
|
||
'gsd-bar/agents/other.txt must be preserved');
|
||
|
||
// helper: non-gsd dir untouched
|
||
assert.ok(fs.existsSync(path.join(helperAgents, 'openai.yaml')),
|
||
'helper/agents/openai.yaml must be untouched (non-gsd dir)');
|
||
});
|
||
|
||
test('is a no-op when skillsDir does not exist (#1326)', () => {
|
||
assert.doesNotThrow(() => {
|
||
cleanupCodexSkillMetadataSidecars(path.join(tmpDir, 'nonexistent'));
|
||
}, 'must not throw when skillsDir does not exist');
|
||
});
|
||
|
||
test('is a no-op for managed gsd-* dirs with no agents/openai.yaml (#1326)', () => {
|
||
// No sidecar present — should not throw, should not create anything
|
||
const skillDir = path.join(tmpDir, 'gsd-baz');
|
||
fs.mkdirSync(skillDir, { recursive: true });
|
||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: gsd-baz\n---\nBody.\n');
|
||
|
||
assert.doesNotThrow(() => {
|
||
cleanupCodexSkillMetadataSidecars(tmpDir);
|
||
}, 'must not throw when no sidecar exists');
|
||
assert.ok(!fs.existsSync(path.join(skillDir, 'agents')),
|
||
'must not create agents/ dir when no sidecar was present');
|
||
});
|
||
|
||
test('does not delete through a symlinked agents/ directory (#1326)', { skip: process.platform === 'win32' }, () => {
|
||
// Setup: a skills dir with gsd-foo/ whose agents/ is a SYMLINK to an external dir.
|
||
// The cleanup must not delete files through the symlink.
|
||
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-symlink-ext-'));
|
||
try {
|
||
// Place openai.yaml and a sentinel in the external dir.
|
||
fs.writeFileSync(path.join(externalDir, 'openai.yaml'), 'interface:\n display_name: "external"\n');
|
||
fs.writeFileSync(path.join(externalDir, 'keep.txt'), 'sentinel\n');
|
||
|
||
// Create gsd-foo/ in the skills dir and make agents/ a symlink to externalDir.
|
||
const skillDir = path.join(tmpDir, 'gsd-foo');
|
||
fs.mkdirSync(skillDir, { recursive: true });
|
||
const agentsLink = path.join(skillDir, 'agents');
|
||
fs.symlinkSync(externalDir, agentsLink, 'dir');
|
||
|
||
cleanupCodexSkillMetadataSidecars(tmpDir);
|
||
|
||
// Nothing in the external dir must have been deleted.
|
||
assert.ok(fs.existsSync(path.join(externalDir, 'openai.yaml')),
|
||
'external/openai.yaml must still exist — cleanup must not delete through a symlinked agents/ dir');
|
||
assert.ok(fs.existsSync(path.join(externalDir, 'keep.txt')),
|
||
'external/keep.txt must still exist — cleanup must not delete through a symlinked agents/ dir');
|
||
// The symlink itself must still be present.
|
||
assert.ok(fs.existsSync(agentsLink),
|
||
'gsd-foo/agents symlink must still exist');
|
||
} finally {
|
||
cleanup(externalDir);
|
||
}
|
||
});
|
||
|
||
test('Codex install does not create agents/openai.yaml sidecars for any managed skill (#1326)', () => {
|
||
// Integration test: full Codex install must NOT produce any managed gsd-*/agents/openai.yaml
|
||
const codexHome = path.join(tmpDir, 'codex-home');
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
runCodexInstall(codexHome);
|
||
const skillsDir = codexSkillsRoot(codexHome);
|
||
assert.ok(fs.existsSync(skillsDir), 'Codex install must create a skills/ directory');
|
||
const gsdSkillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-') && e.name !== 'gsd-dev-preferences');
|
||
assert.ok(gsdSkillDirs.length > 0, 'install must create at least one managed gsd-* skill directory');
|
||
for (const skillEntry of gsdSkillDirs) {
|
||
const yamlPath = path.join(skillsDir, skillEntry.name, 'agents', 'openai.yaml');
|
||
assert.ok(!fs.existsSync(yamlPath),
|
||
`${skillEntry.name}/agents/openai.yaml must NOT exist after install (#1326 sidecar dedup)`);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2698-crlf-install.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2698-crlf-install (consolidation epic #1969 B1 #1970)", () => {
|
||
// allow-test-rule: source-text-is-the-product (see #2698)
|
||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||
// Per CONTRIBUTING.md exception matrix.
|
||
|
||
/**
|
||
* Regression test for #2698: CRLF line endings break agent-block strip regexes
|
||
*
|
||
* The legacy `gsd-update-check` hook migration in bin/install.js uses two
|
||
* separate .replace() calls:
|
||
* 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/
|
||
* 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/
|
||
*
|
||
* These patterns fail when config.toml has mixed line endings — e.g. the
|
||
* "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This
|
||
* can happen when the file is created cross-platform (Windows/Linux), when
|
||
* editors convert only part of the file, or when a previous GSD version wrote
|
||
* the block with different EOL than the file's dominant EOL.
|
||
*
|
||
* Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and
|
||
* any mix in a single pass, making the migration robust regardless of the
|
||
* platform the file was last written on.
|
||
*
|
||
* Test approach: write a `.codex/config.toml` with a stale gsd-update-check
|
||
* block that uses mixed line endings (header in LF, body in CRLF), then run
|
||
* install() and assert the stale block is gone.
|
||
*
|
||
* Note: The local Codex install writes to `.codex/` in the current directory.
|
||
* Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test, 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 INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||
const BUILD_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');
|
||
const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC);
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
// Ensure hooks/dist/ is populated before install tests
|
||
before(() => {
|
||
throwIfFailed(
|
||
runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
||
`node ${BUILD_SCRIPT}`,
|
||
);
|
||
});
|
||
|
||
describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => {
|
||
let tmpDir;
|
||
let _previousHome;
|
||
let _previousUserProfile;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-'));
|
||
// #2088 (ADR-1239 upgrade 3): Codex's skills-kind `home: ".agents"` override
|
||
// applies to BOTH global and local scope and resolves via os.homedir(). This
|
||
// describe block calls install(false, 'codex') (local scope) directly —
|
||
// without sandboxing HOME/USERPROFILE to tmpDir, that in-process install
|
||
// would materialize a full gsd-* skill set into the developer/CI machine's
|
||
// REAL $HOME/.agents/skills instead of the temp dir.
|
||
_previousHome = process.env.HOME;
|
||
_previousUserProfile = process.env.USERPROFILE;
|
||
process.env.HOME = tmpDir;
|
||
process.env.USERPROFILE = tmpDir;
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (_previousHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = _previousHome;
|
||
if (_previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = _previousUserProfile;
|
||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block
|
||
// using the given line ending for the stale hooks block header, and a potentially
|
||
// different EOL for the hooks body. This exercises the cross-platform mixed scenario.
|
||
function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) {
|
||
// Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL
|
||
// for the content lines (simulates a file edited by two different platforms).
|
||
const staleBlock = [
|
||
'# GSD Hooks', // line that starts the stale section
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "node /old/path/gsd-update-check.js"',
|
||
].join(bodyEol);
|
||
|
||
// Put the stale block in user content BEFORE the GSD marker. The GSD marker area
|
||
// will be regenerated by mergeCodexConfig during install(); the stale block in
|
||
// the user area is what the hooks migration must remove.
|
||
const content = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol;
|
||
|
||
const codexDir = path.join(dir, '.codex');
|
||
fs.mkdirSync(codexDir, { recursive: true });
|
||
const configPath = path.join(codexDir, 'config.toml');
|
||
fs.writeFileSync(configPath, content, 'utf-8');
|
||
return configPath;
|
||
}
|
||
|
||
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')
|
||
: []),
|
||
]);
|
||
}
|
||
|
||
test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||
const origCwd = process.cwd();
|
||
t.after(() => { process.chdir(origCwd); });
|
||
process.chdir(tmpDir);
|
||
|
||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n');
|
||
install(false, 'codex');
|
||
|
||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||
const content = fs.readFileSync(configPath, 'utf-8');
|
||
|
||
assert.ok(
|
||
!content.includes('gsd-update-check'),
|
||
'Stale gsd-update-check entry must be removed from LF config.toml (#2698)'
|
||
);
|
||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||
assert.equal(
|
||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||
true,
|
||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||
);
|
||
});
|
||
|
||
test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||
const origCwd = process.cwd();
|
||
t.after(() => { process.chdir(origCwd); });
|
||
process.chdir(tmpDir);
|
||
|
||
writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n');
|
||
install(false, 'codex');
|
||
|
||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||
const content = fs.readFileSync(configPath, 'utf-8');
|
||
|
||
assert.ok(
|
||
!content.includes('gsd-update-check'),
|
||
'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)'
|
||
);
|
||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||
assert.equal(
|
||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||
true,
|
||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||
);
|
||
});
|
||
|
||
test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => {
|
||
// This is the primary failure case: header line uses LF but the body uses CRLF.
|
||
// The old LF-only regex requires all-\n separators; the old CRLF-only regex requires
|
||
// all-\r\n separators. Neither matches a block with mixed endings, so the stale
|
||
// block survives reinstall with the old code (#2698).
|
||
const origCwd = process.cwd();
|
||
t.after(() => { process.chdir(origCwd); });
|
||
process.chdir(tmpDir);
|
||
|
||
// headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform)
|
||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n');
|
||
install(false, 'codex');
|
||
|
||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||
const content = fs.readFileSync(configPath, 'utf-8');
|
||
|
||
assert.ok(
|
||
!content.includes('gsd-update-check'),
|
||
[
|
||
'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).',
|
||
'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.',
|
||
'Fix consolidates to a single \\r?\\n-aware regex.',
|
||
].join(' ')
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2760-codex-install-defensive.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2760-codex-install-defensive (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression: issue #2760 — Codex install path corrupts existing config.toml.
|
||
*
|
||
* Three defects, three fixes (defensive triple):
|
||
*
|
||
* Defect 3 (confirmed real) — Hooks AoT downgrade. When the user already has
|
||
* `[[hooks.SessionStart]]` (namespaced AoT) entries in their config, GSD
|
||
* used to append a `[[hooks]]` (top-level AoT) block that confuses
|
||
* round-trip writers and produces a config Codex refuses to load.
|
||
* Fix: detect the user's preferred shape and emit GSD's hook in the same
|
||
* namespaced form so both coexist cleanly.
|
||
*
|
||
* Defects 1+2 (defensive) — Strip-step robustness. Pre-existing legacy
|
||
* `[agents]` (single-bracket) and `[[agents]]` (sequence) blocks are
|
||
* invalid in current Codex schema and break Codex even though GSD now
|
||
* emits the correct `[agents.<name>]` struct form. Fix: install-time
|
||
* stripping always purges these forms regardless of GSD marker presence
|
||
* so reinstall self-heals files where the marker was edited out or never
|
||
* existed (third-party tools).
|
||
*
|
||
* Fix 3 (defensive) — Post-write validation. Parse the bytes we are about
|
||
* to commit, assert they match Codex's expected schema (no bare/sequence
|
||
* `agents`, no bare `hooks.<Event>`); on failure, restore the pre-install
|
||
* backup and abort so the user never gets a broken Codex CLI.
|
||
*/
|
||
|
||
// Scope GSD_TEST_MODE to module load only — restore prior value (or unset) so
|
||
// downstream tests in the same node process never see test-only behaviour
|
||
// leak through (#2760 CR4 finding 5).
|
||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
|
||
const {
|
||
install,
|
||
validateCodexConfigSchema,
|
||
hasUserNamespacedAotHooks,
|
||
parseTomlToObject,
|
||
} = require('../bin/install.js');
|
||
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
if (previousGsdTestMode === undefined) {
|
||
delete process.env.GSD_TEST_MODE;
|
||
} else {
|
||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||
}
|
||
|
||
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
||
const previousCodeHome = process.env.CODEX_HOME;
|
||
const previousCwd = process.cwd();
|
||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
||
// in-process install never materializes skills under the developer/CI
|
||
// machine's real home directory.
|
||
const previousHome = process.env.HOME;
|
||
const previousUserProfile = process.env.USERPROFILE;
|
||
process.env.CODEX_HOME = codexHome;
|
||
process.env.HOME = codexHome;
|
||
process.env.USERPROFILE = codexHome;
|
||
try {
|
||
process.chdir(cwd);
|
||
return install(true, 'codex');
|
||
} finally {
|
||
process.chdir(previousCwd);
|
||
if (previousCodeHome === undefined) {
|
||
delete process.env.CODEX_HOME;
|
||
} else {
|
||
process.env.CODEX_HOME = previousCodeHome;
|
||
}
|
||
if (previousHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = previousHome;
|
||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = previousUserProfile;
|
||
}
|
||
}
|
||
|
||
function readCodexConfig(codexHome) {
|
||
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
}
|
||
|
||
function writeCodexConfig(codexHome, content) {
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
||
}
|
||
|
||
function readCodexHooksJson(codexHome) {
|
||
const hooksPath = path.join(codexHome, 'hooks.json');
|
||
if (!fs.existsSync(hooksPath)) return {};
|
||
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
|
||
if (!raw) return {};
|
||
return JSON.parse(raw);
|
||
}
|
||
|
||
function readHooksSessionStartCommands(codexHome) {
|
||
const parsed = readCodexHooksJson(codexHome);
|
||
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
||
? parsed.hooks
|
||
: parsed;
|
||
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
||
return sessionStart.flatMap((entry) =>
|
||
(Array.isArray(entry?.hooks) ? entry.hooks : [])
|
||
.map((hook) => hook && hook.command)
|
||
.filter((cmd) => typeof cmd === 'string')
|
||
);
|
||
}
|
||
|
||
describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/reinstall', () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-d3-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('fresh install emits the two-level nested AoT schema (#2773)', () => {
|
||
// Codex 0.124.0+ requires [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]]
|
||
// with type = "command". Neither the flat [[hooks]] + event field form nor
|
||
// the single-block [[hooks.SessionStart]] form without .hooks is accepted.
|
||
writeCodexConfig(codexHome, '');
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(content);
|
||
|
||
const sessionStartCommands = readHooksSessionStartCommands(codexHome);
|
||
const managed = sessionStartCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
||
assert.equal(managed.length, 1, 'hooks.json must contain exactly one managed gsd-check-update command');
|
||
assert.ok(
|
||
!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart),
|
||
'config.toml should not carry managed SessionStart hooks for GSD'
|
||
);
|
||
});
|
||
|
||
test('preserves user [[hooks.SessionStart]] entries and registers managed GSD handler in hooks.json', () => {
|
||
// Users may have their own [[hooks.SessionStart]] entries using the new schema.
|
||
// GSD must append its own two-level block without disturbing theirs.
|
||
const userConfig = [
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "echo first user hook"',
|
||
'',
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "echo second user hook"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, userConfig);
|
||
|
||
runCodexInstall(codexHome);
|
||
const afterInstall = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(afterInstall);
|
||
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'hooks.SessionStart must remain an array-of-tables after install'
|
||
);
|
||
|
||
// Collect all handler commands across all event entries.
|
||
const allCommands = parsed.hooks.SessionStart.flatMap((entry) =>
|
||
Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []
|
||
);
|
||
|
||
assert.ok(
|
||
allCommands.includes('echo first user hook'),
|
||
'first user hook preserved: ' + JSON.stringify(allCommands)
|
||
);
|
||
assert.ok(
|
||
allCommands.includes('echo second user hook'),
|
||
'second user hook preserved: ' + JSON.stringify(allCommands)
|
||
);
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
assert.ok(
|
||
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
||
'GSD handler must appear in hooks.json SessionStart entries: ' + JSON.stringify(hooksJsonCommands)
|
||
);
|
||
assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] entries');
|
||
});
|
||
|
||
test('reinstall replaces flat [[hooks]] + event form with nested schema', () => {
|
||
// Upgrade path: user has a config written by GSD 1.38.x (flat [[hooks]] form).
|
||
const legacyConfig = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'# GSD Hooks',
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "node /old/path/to/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, legacyConfig);
|
||
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(content);
|
||
|
||
// Old flat form must be gone.
|
||
assert.ok(!Array.isArray(parsed.hooks), 'flat [[hooks]] must be stripped on upgrade');
|
||
// Only one GSD hook entry must exist (no duplication) in hooks.json.
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
||
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade');
|
||
});
|
||
|
||
test('reinstall replaces single-block [[hooks.SessionStart]] (no .hooks sub-table) with nested schema', () => {
|
||
// Upgrade path: user has a config written by the PR #2802 shape —
|
||
// [[hooks.SessionStart]] without a nested [[hooks.SessionStart.hooks]] sub-table.
|
||
const prBranchConfig = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'# GSD Hooks',
|
||
'[[hooks.SessionStart]]',
|
||
'command = "node /old/path/to/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, prBranchConfig);
|
||
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
parseTomlToObject(content);
|
||
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
||
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed handler after upgrade from PR-#2802-shape');
|
||
});
|
||
|
||
test('reinstall is idempotent: correct nested schema is stripped and re-emitted cleanly', () => {
|
||
writeCodexConfig(codexHome, '');
|
||
runCodexInstall(codexHome);
|
||
runCodexInstall(codexHome); // second install
|
||
readCodexConfig(codexHome);
|
||
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd));
|
||
assert.strictEqual(gsdHandlers.length, 1, 'exactly one managed SessionStart handler after double install');
|
||
});
|
||
});
|
||
|
||
describe('#2760 fix 2 — Strip purges invalid legacy [agents] / [[agents]] regardless of marker', () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f2-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('strips bare [agents] single-bracket block (no GSD marker, arbitrary user keys)', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[agents]',
|
||
'default = "custom-agent"',
|
||
'extra_key = "value"',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(content);
|
||
|
||
// Bare [agents] would have left { default, extra_key } as scalar leaves
|
||
// on parsed.agents. After strip + re-emit, only GSD's own managed
|
||
// AgentsToml scalar (max_depth) remains — #2406 stopped emitting
|
||
// [agents.<name>] role sub-tables entirely, so `agents` stays a flat
|
||
// scalar-only object, not a table-of-tables.
|
||
assert.ok(
|
||
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
||
'agents must be an object in parsed structure, got: ' + typeof parsed.agents
|
||
);
|
||
assert.equal(parsed.agents.default, undefined, 'bare [agents] default key must be stripped');
|
||
assert.equal(parsed.agents.extra_key, undefined, 'bare [agents] extra_key must be stripped');
|
||
assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key');
|
||
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
||
assert.deepStrictEqual(
|
||
gsdAgents, [],
|
||
'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents))
|
||
);
|
||
|
||
// User's unrelated [model] section preserved structurally.
|
||
assert.ok(
|
||
parsed.model && parsed.model.name === 'o3',
|
||
'unrelated user [model] section preserved with name = "o3", got: ' + JSON.stringify(parsed.model)
|
||
);
|
||
});
|
||
|
||
test('strips [[agents]] sequence-form block without GSD marker (third-party / marker-edited-out)', () => {
|
||
writeCodexConfig(codexHome, [
|
||
'[[agents]]',
|
||
'name = "user-helper"',
|
||
'description = "third-party agent"',
|
||
'',
|
||
'[[agents]]',
|
||
'name = "another-helper"',
|
||
'description = "second one"',
|
||
'',
|
||
'[projects."/tmp/x"]',
|
||
'trust_level = "trusted"',
|
||
'',
|
||
].join('\n'));
|
||
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(content);
|
||
|
||
// [[agents]] sequence form would parse to Array — after strip it must be
|
||
// a plain object holding only GSD's own managed max_depth scalar (#2406
|
||
// stopped emitting [agents.<name>] role sub-tables entirely).
|
||
assert.ok(
|
||
parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents),
|
||
'agents must be an object in parsed structure (sequence form must be stripped), got: '
|
||
+ (Array.isArray(parsed.agents) ? 'array' : typeof parsed.agents)
|
||
);
|
||
assert.equal(parsed.agents.max_depth, 1, 'GSD-managed max_depth is the only surviving [agents] key');
|
||
const gsdAgents = Object.keys(parsed.agents).filter((k) => k.startsWith('gsd-'));
|
||
assert.deepStrictEqual(
|
||
gsdAgents, [],
|
||
'no [agents.gsd-*] role sub-tables (#2406) — canonical registration lives only in the standalone TOMLs: ' + JSON.stringify(Object.keys(parsed.agents))
|
||
);
|
||
|
||
// User's unrelated [projects."/tmp/x"] section preserved structurally.
|
||
assert.ok(
|
||
parsed.projects && parsed.projects['/tmp/x'] && parsed.projects['/tmp/x'].trust_level === 'trusted',
|
||
'unrelated user [projects."/tmp/x"] section preserved with trust_level = "trusted", got: '
|
||
+ JSON.stringify(parsed.projects)
|
||
);
|
||
});
|
||
});
|
||
|
||
// concurrency: false — the third test mutates installModule.__codexSchemaValidator,
|
||
// a module-level test seam. Other tests in this file (and in bug-2153, etc.)
|
||
// also call runCodexInstall() and would observe the injected validator if
|
||
// node:test ran them in parallel. Serializing this describe block keeps the
|
||
// seam mutation invisible to siblings.
|
||
describe('#2760 fix 3 — Post-write Codex schema validation', { concurrency: false }, () => {
|
||
test('passes a clean config produced by GSD install', () => {
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3a-'));
|
||
try {
|
||
const codexHome = path.join(tmpDir, 'codex-home');
|
||
runCodexInstall(codexHome);
|
||
const content = readCodexConfig(codexHome);
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.equal(result.ok, true, 'GSD-emitted config passes schema validation');
|
||
} finally {
|
||
cleanup(tmpDir);
|
||
}
|
||
});
|
||
|
||
test('rejects bare [agents] and bare [hooks.SessionStart] in arbitrary content', () => {
|
||
const bareAgents = [
|
||
'[agents]',
|
||
'default = "x"',
|
||
'',
|
||
].join('\n');
|
||
const bareHooks = [
|
||
'[hooks.SessionStart]',
|
||
'command = "x"',
|
||
'',
|
||
].join('\n');
|
||
const sequenceAgents = [
|
||
'[[agents]]',
|
||
'name = "x"',
|
||
'',
|
||
].join('\n');
|
||
|
||
assert.equal(validateCodexConfigSchema(bareAgents).ok, false, 'bare [agents] rejected');
|
||
assert.equal(validateCodexConfigSchema(bareHooks).ok, false, 'bare [hooks.SessionStart] rejected');
|
||
assert.equal(validateCodexConfigSchema(sequenceAgents).ok, false, '[[agents]] sequence rejected');
|
||
});
|
||
|
||
test('aborts install and restores pre-install backup when post-write validation fails', () => {
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f3b-'));
|
||
const installModule = require('../bin/install.js');
|
||
try {
|
||
const codexHome = path.join(tmpDir, 'codex-home');
|
||
// Pre-install file the user wants protected.
|
||
const preInstall = [
|
||
'# user file',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
// Force the post-write validator to fail via the documented test seam.
|
||
// This simulates the writer producing legacy-form output that Codex
|
||
// would reject — install MUST abort, restore the pre-install bytes,
|
||
// and surface a clear error.
|
||
installModule.__codexSchemaValidator = () => ({
|
||
ok: false,
|
||
reason: 'simulated invalid output for test',
|
||
});
|
||
|
||
let threw = false;
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (e) {
|
||
threw = true;
|
||
assert.match(
|
||
e.message,
|
||
/post-write Codex schema validation failed/,
|
||
'thrown error names the validation failure'
|
||
);
|
||
assert.match(e.message, /simulated invalid output for test/, 'thrown error includes reason');
|
||
}
|
||
assert.equal(threw, true, 'install threw when validator failed');
|
||
|
||
const afterInstall = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
assert.equal(
|
||
afterInstall,
|
||
preInstall,
|
||
'pre-install file restored verbatim after validation failure'
|
||
);
|
||
} finally {
|
||
delete installModule.__codexSchemaValidator;
|
||
cleanup(tmpDir);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('#2760 — hasUserNamespacedAotHooks helper', () => {
|
||
test('detects [[hooks.SessionStart]] AoT entries', () => {
|
||
const content = [
|
||
'[[hooks.SessionStart]]',
|
||
'command = "x"',
|
||
'',
|
||
].join('\n');
|
||
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), true);
|
||
});
|
||
|
||
test('returns false when only top-level [[hooks]] entries exist', () => {
|
||
const content = [
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "x"',
|
||
'',
|
||
].join('\n');
|
||
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false);
|
||
});
|
||
|
||
test('returns false when only single-bracket [hooks.SessionStart] exists', () => {
|
||
const content = [
|
||
'[hooks.SessionStart]',
|
||
'command = "x"',
|
||
'',
|
||
].join('\n');
|
||
assert.equal(hasUserNamespacedAotHooks(content, 'SessionStart'), false);
|
||
});
|
||
});
|
||
|
||
// concurrency: false — these tests monkey-patch fs.writeFileSync, a global
|
||
// shared with every other suite running in parallel. Serializing prevents
|
||
// stray writes from sibling tests landing in the stub.
|
||
describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restore)', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
let originalWriteFileSync;
|
||
// #2760 CR5 finding 5 — symmetric snapshot/restore for fs.renameSync. The
|
||
// first test below monkey-patches renameSync; without a beforeEach/afterEach
|
||
// pair, only the local `finally` restores it, which is fragile to future
|
||
// edits that add early-return paths.
|
||
let originalRenameSync;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-f4-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
originalWriteFileSync = fs.writeFileSync;
|
||
originalRenameSync = fs.renameSync;
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.renameSync = originalRenameSync;
|
||
fs.writeFileSync = originalWriteFileSync;
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('pre-install config bytes survive when fs.renameSync throws over configPath', () => {
|
||
const preInstall = [
|
||
'# user file',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
// After fs is restored we'll re-read the file. Capture the byte buffer
|
||
// exactly so the comparison is bit-for-bit.
|
||
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
||
|
||
const configPath = path.join(codexHome, 'config.toml');
|
||
const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-');
|
||
|
||
// Stub: allow writes to atomic temp files (which renameSync overwrites
|
||
// the target, never truncating it directly) but throw on any direct
|
||
// write to the canonical configPath. This simulates either:
|
||
// (a) an older code path doing a non-atomic write, or
|
||
// (b) a downstream module bypassing atomicWriteFileSync.
|
||
// Either way the snapshot must be restored. We let the temp write go
|
||
// through, then make renameSync throw to simulate the partial write
|
||
// never landing.
|
||
// #2760 CR5 finding 5 — fs.renameSync is restored by the suite-level
|
||
// afterEach; no local finally needed.
|
||
fs.renameSync = (src, dst) => {
|
||
if (dst === configPath) {
|
||
throw new Error('simulated rename failure mid-install');
|
||
}
|
||
return originalRenameSync(src, dst);
|
||
};
|
||
|
||
let threw = false;
|
||
let thrownErr = null;
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (e) {
|
||
threw = true;
|
||
thrownErr = e;
|
||
assert.ok(/rename failure|simulated|post-write/.test(e.message),
|
||
'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message);
|
||
}
|
||
// #2760 CR5 finding 4 — tighten contract per finding #1: ALL pre-write
|
||
// and write failures must be fatal. This test previously accepted either
|
||
// throw OR warn — sibling tests already require throw, so lock parity.
|
||
assert.equal(threw, true, 'rename failure must be fatal: ' + (thrownErr && thrownErr.message));
|
||
|
||
const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
||
assert.deepStrictEqual(
|
||
afterBytes,
|
||
preInstallBytes,
|
||
'pre-install config.toml bytes must survive a mid-install write/rename failure'
|
||
);
|
||
|
||
// And the parsed structure of the surviving file must still be the
|
||
// user's [model] section, not a half-written GSD block.
|
||
const parsed = parseTomlToObject(afterBytes.toString('utf8'));
|
||
assert.equal(parsed.model && parsed.model.name, 'o3',
|
||
'surviving file must still be the user pre-install content');
|
||
assert.equal(parsed.agents, undefined,
|
||
'no GSD agents block may have leaked into the surviving file');
|
||
|
||
// No stray .tmp-* siblings left behind in the codex home.
|
||
const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f)));
|
||
assert.equal(stray.length, 0,
|
||
'atomic write must clean up its temp file on failure: ' + stray.join(', '));
|
||
});
|
||
|
||
test('pre-install config bytes survive when fs.writeFileSync throws on the .tmp- target', () => {
|
||
const preInstall = [
|
||
'# user file',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
||
const configPath = path.join(codexHome, 'config.toml');
|
||
const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-');
|
||
|
||
// Stub: fault writes targeting the atomic temp file (the pre-rename branch
|
||
// of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME)
|
||
// pass through. This exercises the failure path where the temp write itself
|
||
// throws, not the rename — the case the prior test left untested.
|
||
// #2760 CR5 finding 5 — fs.writeFileSync is restored by the suite-level
|
||
// afterEach (via originalWriteFileSync); no local finally needed.
|
||
const captured = originalWriteFileSync;
|
||
fs.writeFileSync = function patchedWriteFileSync(target, data, options) {
|
||
if (typeof target === 'string' && tempPattern.test(target)) {
|
||
throw new Error('simulated writeFileSync failure on .tmp- target');
|
||
}
|
||
return captured.call(this, target, data, options);
|
||
};
|
||
|
||
let threw = false;
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (e) {
|
||
threw = true;
|
||
assert.ok(/simulated writeFileSync failure|post-write Codex install failed|pre-write/.test(e.message),
|
||
'thrown error must surface the simulated failure or its post-write wrapper: ' + e.message);
|
||
}
|
||
// Per #2760 CR4 finding 1 / CR5 finding 1, write failures must abort install (not warn).
|
||
assert.equal(threw, true, 'install must throw when atomic temp-write fails');
|
||
|
||
const afterBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
|
||
assert.deepStrictEqual(
|
||
afterBytes,
|
||
preInstallBytes,
|
||
'pre-install config.toml bytes must survive a temp-write failure'
|
||
);
|
||
|
||
const parsed = parseTomlToObject(afterBytes.toString('utf8'));
|
||
assert.equal(parsed.model && parsed.model.name, 'o3',
|
||
'surviving file must still be the user pre-install content');
|
||
assert.equal(parsed.agents, undefined,
|
||
'no GSD agents block may have leaked into the surviving file');
|
||
|
||
const stray = fs.readdirSync(codexHome).filter((f) => tempPattern.test(path.join(codexHome, f)));
|
||
assert.equal(stray.length, 0,
|
||
'atomic write must clean up its temp file on failure: ' + stray.join(', '));
|
||
});
|
||
});
|
||
|
||
// concurrency: false — these tests rely on the same install path and module-
|
||
// level pre-install snapshot that the fix-3/fix-4 suites exercise. Serializing
|
||
// keeps state mutations from leaking across parallel siblings.
|
||
describe('#2760 CR4 finding 2 — Legacy flat [[hooks]] block migrates to namespaced AoT on reinstall', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f2-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('pre-install legacy flat [[hooks]] gsd-check-update + user namespaced [[hooks.SessionStart]] → post-install converges on namespaced AoT', () => {
|
||
// Reproduce the upgrade scenario:
|
||
// - User has [[hooks.SessionStart]] entry of their own (signal that GSD
|
||
// should emit in the namespaced shape).
|
||
// - A previous GSD install left the legacy flat [[hooks]] managed block
|
||
// for gsd-check-update. The pre-CR4 strip step would short-circuit
|
||
// the namespaced emit and leave the user stuck in the mixed layout.
|
||
const userPlusLegacy = [
|
||
'[[hooks.SessionStart]]',
|
||
'command = "echo user hook"',
|
||
'',
|
||
'# GSD Hooks',
|
||
'[[hooks]]',
|
||
'event = "SessionStart"',
|
||
'command = "node /old/path/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, userPlusLegacy);
|
||
|
||
runCodexInstall(codexHome);
|
||
const afterInstall = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(afterInstall);
|
||
|
||
// After CR4 finding 2: the legacy flat [[hooks]] managed block is stripped
|
||
// and the GSD entry is re-emitted in the namespaced AoT shape so the two
|
||
// forms do not coexist.
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'hooks.SessionStart must be an array-of-tables, got: '
|
||
+ (parsed.hooks ? typeof parsed.hooks.SessionStart : 'no hooks table')
|
||
);
|
||
|
||
// Migration now handles stale [[hooks.SessionStart]] entries with handler
|
||
// fields at event-entry level (pre-#2773 shape), promoting them to the
|
||
// two-level nested form. Every entry must carry a .hooks sub-array after
|
||
// migration, so collect from nested handlers only.
|
||
assert.ok(
|
||
parsed.hooks.SessionStart.every((entry) => Array.isArray(entry.hooks)),
|
||
'every hooks.SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration'
|
||
);
|
||
const allSessionStartCommands = parsed.hooks.SessionStart.flatMap((entry) =>
|
||
entry.hooks.map((h) => h.command).filter(Boolean)
|
||
);
|
||
assert.ok(
|
||
allSessionStartCommands.includes('echo user hook'),
|
||
'user [[hooks.SessionStart]] entry preserved: ' + JSON.stringify(allSessionStartCommands)
|
||
);
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
assert.ok(
|
||
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
||
'GSD entry must appear in hooks.json SessionStart entries: '
|
||
+ JSON.stringify(hooksJsonCommands)
|
||
);
|
||
|
||
// The legacy top-level [[hooks]] AoT must NOT coexist with the namespaced
|
||
// form after migration. parseTomlToObject distinguishes via Array.isArray.
|
||
assert.ok(
|
||
!Array.isArray(parsed.hooks) || parsed.hooks.length === 0,
|
||
'no top-level [[hooks]] AoT entries may remain after legacy migration: '
|
||
+ JSON.stringify(parsed.hooks)
|
||
);
|
||
|
||
// No duplicate gsd-check-update entries — exactly one managed entry.
|
||
const gsdEntries = hooksJsonCommands.filter((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd));
|
||
assert.equal(gsdEntries.length, 1,
|
||
'exactly one gsd-check-update entry after migration, got: ' + gsdEntries.length);
|
||
});
|
||
});
|
||
|
||
describe('#2760 CR4 finding 3 / #3245 — parseTomlToObject handles edge-case value types (floats accepted; dates/trailing-garbage rejected)', () => {
|
||
// #3245 inverts the float-rejection requirement: Codex CLI's serde schema
|
||
// requires f64 for tool_timeout_sec/startup_timeout_sec, so GSD's parser
|
||
// must now ACCEPT floats. The original guard (from #2760 CR4 finding 3) was
|
||
// "don't silently truncate 0.5 to integer 0" — that goal is still met
|
||
// because we parse the full float as a JS Number (not truncate to prefix).
|
||
test('accepts TOML floats (timeout = 0.5) — #3245 fix', () => {
|
||
const content = [
|
||
'[server]',
|
||
'timeout = 0.5',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.server.timeout, 0.5,
|
||
'float values must be accepted as JS Number (not truncated to 0) — #3245');
|
||
});
|
||
|
||
test('rejects date values (created = 1979-05-27)', () => {
|
||
const content = [
|
||
'[meta]',
|
||
'created = 1979-05-27',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/unsupported TOML value|trailing bytes/,
|
||
'date values must be rejected, not silently truncated'
|
||
);
|
||
});
|
||
|
||
test('rejects trailing garbage after a string value (key = "x" junk)', () => {
|
||
const content = [
|
||
'[section]',
|
||
'key = "x" junk',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/trailing bytes/,
|
||
'trailing bytes after a complete value must be rejected'
|
||
);
|
||
});
|
||
|
||
test('accepts trailing whitespace and # comment after a value', () => {
|
||
const content = [
|
||
'[section]',
|
||
'key = "x" # an inline comment',
|
||
'flag = true',
|
||
'count = 7 ',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.equal(parsed.section.key, 'x');
|
||
assert.equal(parsed.section.flag, true);
|
||
assert.equal(parsed.section.count, 7);
|
||
});
|
||
});
|
||
|
||
// concurrency: false — see the fix-3 suite above for the same rationale.
|
||
describe('#2760 CR4 finding 1 — atomicWriteFileSync failure aborts install (post-write fatal)', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
let originalRenameSync;
|
||
let originalConsoleLog;
|
||
let consoleOutput;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr4-f1-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
originalRenameSync = fs.renameSync;
|
||
originalConsoleLog = console.log;
|
||
consoleOutput = [];
|
||
console.log = (...args) => { consoleOutput.push(args.join(' ')); };
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.renameSync = originalRenameSync;
|
||
console.log = originalConsoleLog;
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('install throws and never prints "Done!" when atomicWriteFileSync fails on configPath', () => {
|
||
const preInstall = [
|
||
'# user file',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
const configPath = path.join(codexHome, 'config.toml');
|
||
// Only fault the hook-block atomic rename — earlier writes to config.toml
|
||
// happen via mergeCodexConfig (agent-block emit). We want to exercise the
|
||
// post-write Codex install branch specifically. Detect by reading the temp
|
||
// file's contents and only faulting when the hook block is present.
|
||
fs.renameSync = (src, dst) => {
|
||
if (dst === configPath) {
|
||
let isHookWrite = false;
|
||
try {
|
||
const data = fs.readFileSync(src, 'utf8');
|
||
isHookWrite = /GSD codex_hooks ownership/.test(data);
|
||
} catch (_) { /* ignore */ }
|
||
if (isHookWrite) {
|
||
throw new Error('simulated rename failure');
|
||
}
|
||
}
|
||
return originalRenameSync(src, dst);
|
||
};
|
||
|
||
let threw = false;
|
||
let thrownMessage = '';
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (e) {
|
||
threw = true;
|
||
thrownMessage = e.message;
|
||
}
|
||
|
||
assert.equal(threw, true, 'install must throw when atomic write fails');
|
||
assert.match(
|
||
thrownMessage,
|
||
/post-write Codex install failed/,
|
||
'thrown error must use the post-write prefix so the outer catch treats it as fatal'
|
||
);
|
||
|
||
// Critical: install must NOT have printed any "Done!" success banner.
|
||
const printedDone = consoleOutput.some(
|
||
(line) => typeof line === 'string' && /Done!/i.test(line)
|
||
);
|
||
assert.equal(printedDone, false,
|
||
'install must NOT print "Done!" after a write failure: ' + JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l))));
|
||
|
||
// And the user's pre-install bytes are intact (snapshot restore).
|
||
const after = fs.readFileSync(configPath, 'utf8');
|
||
assert.equal(after, preInstall, 'pre-install bytes preserved after fatal abort');
|
||
});
|
||
});
|
||
|
||
// concurrency: false — patches module.exports.__codexSchemaValidator, a
|
||
// shared test seam. Serializing prevents stray patches from sibling tests.
|
||
describe('#2760 CR5 finding 1 — pre-write failures abort install (outer catch fatal)', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
let originalConsoleLog;
|
||
let consoleOutput;
|
||
const installModule = require('../bin/install.js');
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f1-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
originalConsoleLog = console.log;
|
||
consoleOutput = [];
|
||
console.log = (...args) => { consoleOutput.push(args.join(' ')); };
|
||
});
|
||
|
||
afterEach(() => {
|
||
console.log = originalConsoleLog;
|
||
delete installModule.__codexSchemaValidator;
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('pre-write throw (validator throws, not returns {ok:false}) is fatal and restores snapshot', () => {
|
||
// A validator that THROWS (vs returning {ok:false}) bypasses the
|
||
// validation branch and exits the inner try via the catch at the outer
|
||
// level. Pre-CR5, that catch downgraded to console.warn and let the
|
||
// install print "Done!" with no Codex hooks. Post-CR5 it must rethrow.
|
||
const preInstall = [
|
||
'# user file',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
installModule.__codexSchemaValidator = () => {
|
||
throw new Error('synthetic validator-throw simulating a pre-write helper failure');
|
||
};
|
||
|
||
let threw = false;
|
||
let thrownMsg = '';
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (e) {
|
||
threw = true;
|
||
thrownMsg = e.message;
|
||
}
|
||
|
||
assert.equal(threw, true,
|
||
'install must rethrow when a pre-write step throws (CR5 finding 1)');
|
||
assert.match(thrownMsg, /pre-write|synthetic validator-throw/,
|
||
'thrown error must surface the pre-write wrapper or original message: ' + thrownMsg);
|
||
|
||
const printedDone = consoleOutput.some(
|
||
(line) => typeof line === 'string' && /Done!/i.test(line)
|
||
);
|
||
assert.equal(printedDone, false,
|
||
'install must NOT print "Done!" after a pre-write failure: ' +
|
||
JSON.stringify(consoleOutput.filter((l) => /Done|✓/.test(l))));
|
||
|
||
// Pre-install bytes intact (snapshot restored).
|
||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
assert.equal(after, preInstall,
|
||
'pre-install bytes must survive a pre-write helper throw');
|
||
});
|
||
});
|
||
|
||
describe('#2760 CR5 finding 2 — parseTomlToObject rejects duplicate keys and shape-mismatched headers', () => {
|
||
test('rejects duplicate scalar key in same table ([a]\\nx=1\\nx=2)', () => {
|
||
const content = [
|
||
'[a]',
|
||
'x = 1',
|
||
'x = 2',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/duplicate key/,
|
||
'real TOML 1.0 rejects duplicate keys in the same table'
|
||
);
|
||
});
|
||
|
||
test('rejects duplicate scalar key in root table', () => {
|
||
const content = [
|
||
'x = 1',
|
||
'x = 2',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/duplicate key/,
|
||
'duplicate root-table keys must be rejected'
|
||
);
|
||
});
|
||
|
||
test('rejects re-declared [a] table header ([a] then [a] again)', () => {
|
||
const content = [
|
||
'[a]',
|
||
'x = 1',
|
||
'',
|
||
'[a]',
|
||
'y = 2',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/duplicate or shape-mismatched table header/,
|
||
'real TOML 1.0 rejects re-declaring the same [a] header twice'
|
||
);
|
||
});
|
||
|
||
test('rejects [[arr]] then [arr] for same path (array-of-tables → table)', () => {
|
||
const content = [
|
||
'[[arr]]',
|
||
'x = 1',
|
||
'',
|
||
'[arr]',
|
||
'y = 2',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/duplicate or shape-mismatched table header/,
|
||
'cannot redeclare an array-of-tables path as a plain table'
|
||
);
|
||
});
|
||
|
||
test('accepts repeated [[arr]] (genuine array-of-tables)', () => {
|
||
const content = [
|
||
'[[arr]]',
|
||
'x = 1',
|
||
'',
|
||
'[[arr]]',
|
||
'x = 2',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.ok(Array.isArray(parsed.arr));
|
||
assert.strictEqual(parsed.arr.length, 2);
|
||
assert.strictEqual(parsed.arr[0].x, 1);
|
||
assert.strictEqual(parsed.arr[1].x, 2);
|
||
});
|
||
|
||
test('accepts disjoint nested headers (not duplicates)', () => {
|
||
const content = [
|
||
'[a.b]',
|
||
'x = 1',
|
||
'',
|
||
'[a.c]',
|
||
'y = 2',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.a.b.x, 1);
|
||
assert.strictEqual(parsed.a.c.y, 2);
|
||
});
|
||
});
|
||
|
||
// concurrency: false — drives the same install pipeline as the other f-suites.
|
||
describe('#2760 CR5 finding 3 — migration emits namespaced AoT (no flat/namespaced mixing)', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2760-cr5-f3-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('user has [[hooks.AfterTool]] AND legacy [hooks.SessionStart] → post-install both namespaced, no flat AoT', () => {
|
||
// Reproduces the mixed-form scenario from finding 3:
|
||
// - User pre-config has both a namespaced AoT entry [[hooks.AfterTool]]
|
||
// AND a legacy single-bracket [hooks.SessionStart].
|
||
// - Pre-CR5 migration converts the legacy section to flat [[hooks]]
|
||
// with event="SessionStart", leaving a mixed flat+namespaced layout.
|
||
// - Post-CR5 migration emits [[hooks.SessionStart]] directly so both
|
||
// of the user's hooks coexist in the namespaced shape, and the
|
||
// GSD-managed entry converges on namespaced too.
|
||
const userPlusLegacy = [
|
||
'[[hooks.AfterTool]]',
|
||
'command = "x"',
|
||
'',
|
||
'[hooks.SessionStart]',
|
||
'command = "y"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, userPlusLegacy);
|
||
|
||
runCodexInstall(codexHome);
|
||
const after = readCodexConfig(codexHome);
|
||
const parsed = parseTomlToObject(after);
|
||
|
||
// The pre-existing [[hooks.AfterTool]] entry is preserved.
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks.AfterTool),
|
||
'pre-existing [[hooks.AfterTool]] must remain a namespaced AoT array'
|
||
);
|
||
// AfterTool was in [[hooks.AfterTool]] with command at event-entry level
|
||
// (pre-#2773 stale namespaced AoT shape). Migration now promotes these to
|
||
// the two-level nested form, so every entry must have a .hooks sub-array.
|
||
assert.ok(
|
||
parsed.hooks.AfterTool.every((e) => Array.isArray(e.hooks)),
|
||
'every AfterTool entry must use nested [[hooks.AfterTool.hooks]] handlers after migration'
|
||
);
|
||
const afterToolCommands = parsed.hooks.AfterTool.flatMap((e) =>
|
||
e.hooks.map((h) => h.command).filter(Boolean)
|
||
);
|
||
assert.ok(
|
||
afterToolCommands.includes('x'),
|
||
'user AfterTool entry must be preserved: ' + JSON.stringify(afterToolCommands)
|
||
);
|
||
|
||
// The migrated SessionStart entry is now namespaced AoT with nested .hooks sub-table.
|
||
assert.ok(
|
||
parsed.hooks && Array.isArray(parsed.hooks.SessionStart),
|
||
'migrated SessionStart must be namespaced AoT (not flat [[hooks]])'
|
||
);
|
||
// After migration, [hooks.SessionStart] map-format is promoted to nested AoT.
|
||
// Command lives in [[hooks.SessionStart.hooks]][0].command (nested schema).
|
||
assert.ok(
|
||
parsed.hooks.SessionStart.every((e) => Array.isArray(e.hooks)),
|
||
'every SessionStart entry must use nested [[hooks.SessionStart.hooks]] handlers after migration'
|
||
);
|
||
const ssCommands = parsed.hooks.SessionStart.flatMap((e) =>
|
||
e.hooks.map((h) => h.command).filter(Boolean)
|
||
);
|
||
assert.ok(
|
||
ssCommands.includes('y'),
|
||
'user SessionStart command "y" must be preserved in namespaced array: ' +
|
||
JSON.stringify(ssCommands)
|
||
);
|
||
// GSD's managed gsd-check-update entry also lives in the namespaced array.
|
||
const hooksJsonCommands = readHooksSessionStartCommands(codexHome);
|
||
assert.ok(
|
||
hooksJsonCommands.some((cmd) => typeof cmd === 'string' && /gsd-check-update/.test(cmd)),
|
||
'managed gsd-check-update entry must appear in hooks.json SessionStart entries: ' +
|
||
JSON.stringify(hooksJsonCommands)
|
||
);
|
||
|
||
// No flat top-level [[hooks]] AoT may remain.
|
||
assert.ok(
|
||
!Array.isArray(parsed.hooks) || parsed.hooks.length === 0,
|
||
'no flat top-level [[hooks]] AoT entries may remain after migration: ' +
|
||
JSON.stringify(parsed.hooks)
|
||
);
|
||
|
||
// No synthetic event field on the migrated SessionStart entries — the
|
||
// namespace IS the event.
|
||
for (const entry of parsed.hooks.SessionStart) {
|
||
assert.equal(entry.event, undefined,
|
||
'no synthetic event field — namespace [[hooks.SessionStart]] encodes the event: ' +
|
||
JSON.stringify(entry));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-279-codex-agent-mapping.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-279-codex-agent-mapping (consolidation epic #1969 B1 #1970)", () => {
|
||
'use strict';
|
||
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||
|
||
describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => {
|
||
test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => {
|
||
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
assert.ok(
|
||
/Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
/Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent',
|
||
);
|
||
});
|
||
|
||
test('adapter includes deferred tool_search discovery guidance before inline fallback', () => {
|
||
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
assert.ok(
|
||
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279)
|
||
'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3017-codex-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3017-codex-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => {
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
/**
|
||
* Bug #3017: Codex SessionStart hook still emits bare `node` after #3002.
|
||
*
|
||
* PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude
|
||
* Code, Gemini, Antigravity) by routing through buildHookCommand() →
|
||
* resolveNodeRunner(), which emits the absolute Node binary path. But the
|
||
* Codex install path writes its SessionStart hook directly into a
|
||
* config.toml string, bypassing both helpers:
|
||
*
|
||
* command = "node ${updateCheckScript}"
|
||
*
|
||
* Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node
|
||
* is not resolvable, the hook fails with `/bin/sh: node: command not
|
||
* found` (exit 127). The same failure mode #2979 was meant to fix —
|
||
* just on the codex toml branch instead of the settings.json branch.
|
||
*
|
||
* The fix exposes two pure helpers and tests them as typed records,
|
||
* not by grepping install.js content:
|
||
*
|
||
* buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string
|
||
* - emits `command = "<absoluteRunner> <quoted hook path>"` so the
|
||
* hook resolves under minimal PATH.
|
||
* - returns null when absoluteRunner is null (caller skips with warn,
|
||
* matching settings.json branch behavior).
|
||
*
|
||
* rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed }
|
||
* - rewrites an existing bare-node managed-hook command on reinstall
|
||
* (matches the rewriteLegacyManagedNodeHookCommands shape from #3002).
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const HOOKS_SURFACE = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-hooks-surface.cjs'));
|
||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||
const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = HOOKS_SURFACE;
|
||
const { projectCodexHookTomlCommand } = projection;
|
||
|
||
/**
|
||
* Parse the toml hook block into a typed record so tests can assert on
|
||
* the structured shape (what's the runner, what's the hook path, what's
|
||
* the type) rather than substring-matching the toml text.
|
||
*/
|
||
function parseCodexHookBlock(block) {
|
||
if (!block) return { ok: false, reason: 'empty' };
|
||
// The block always carries the "# GSD Hooks" marker, the AoT tables,
|
||
// a type=command, and a command="<runner> <quoted-hook-path>" line.
|
||
const hasMarker = /^# GSD Hooks$/m.test(block);
|
||
const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block);
|
||
const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block);
|
||
const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m);
|
||
// command = "<runner> <hookpath>" — runner may itself be a quoted absolute path.
|
||
// Match the whole RHS as one toml double-quoted string, then split into runner + hookpath.
|
||
const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m);
|
||
if (!cmdLine) return { ok: false, reason: 'no command line' };
|
||
const cmdValue = cmdLine[1];
|
||
// Inside the command value, the runner is either a quoted string (escaped \" in toml)
|
||
// or a bare token, followed by a space and the hook path (quoted).
|
||
// toml escapes interior " as \", so the cmdValue contains literal \" sequences.
|
||
const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/);
|
||
return {
|
||
ok: true,
|
||
hasMarker,
|
||
hasEvent,
|
||
hasHandler,
|
||
type: typeMatch ? typeMatch[1] : null,
|
||
command: cmdValue,
|
||
runner: cmdParsed ? cmdParsed[1] : null,
|
||
hookPath: cmdParsed ? cmdParsed[2] : null,
|
||
};
|
||
}
|
||
|
||
// Strip the toml-escape (\") and JSON-quote (") layers from the parsed
|
||
// runner token to compare against the raw absolute path the caller
|
||
// supplied. parsed.runner round-trips through TWO escape layers:
|
||
// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes
|
||
// 2. toml escapes the interior " to \" inside the command field
|
||
// After both, parsed.runner ends in `\"` and starts with `\"`.
|
||
function unescapeRunner(token) {
|
||
if (!token) return token;
|
||
let t = token.replace(/^\\"/, '').replace(/\\"$/, '');
|
||
if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1);
|
||
return t;
|
||
}
|
||
|
||
describe('Bug #3017 / #3440: Codex hook projection seam', () => {
|
||
test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => {
|
||
const commandValue = projectCodexHookTomlCommand({
|
||
absoluteRunner: '"/usr/local/bin/node"',
|
||
scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js',
|
||
platform: 'linux',
|
||
});
|
||
assert.equal(
|
||
commandValue,
|
||
'\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => {
|
||
test('exported as a function', () => {
|
||
assert.equal(typeof buildCodexHookBlock, 'function');
|
||
});
|
||
|
||
test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => {
|
||
const targetDir = '/tmp/codex-test/.codex';
|
||
const expectedRunnerPath = '/usr/local/bin/node';
|
||
const absoluteRunner = `"${expectedRunnerPath}"`;
|
||
const block = buildCodexHookBlock(targetDir, { absoluteRunner });
|
||
const parsed = parseCodexHookBlock(block);
|
||
assert.equal(parsed.ok, true, `parse failed: ${block}`);
|
||
assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present');
|
||
assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present');
|
||
assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present');
|
||
assert.equal(parsed.type, 'command', 'handler is type=command');
|
||
// Strict: parsed runner must match the supplied absolute path EXACTLY
|
||
// (after stripping toml/JSON escape layers). A loose substring like
|
||
// '/node' would let an unrelated absolute token containing '/node'
|
||
// pass — e.g. '/Users/x/notnode/foo'.
|
||
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
||
`parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`);
|
||
// On Windows, path.resolve prepends the current drive letter ("D:") to
|
||
// the POSIX-shaped fixture path. Accept either form.
|
||
const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js';
|
||
assert.ok(
|
||
parsed.hookPath === expectedHookSuffix ||
|
||
parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix,
|
||
`hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`,
|
||
);
|
||
});
|
||
|
||
test('returns null when absoluteRunner is null (caller skips registration)', () => {
|
||
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null });
|
||
assert.equal(block, null,
|
||
'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook');
|
||
});
|
||
|
||
test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => {
|
||
const runner = resolveNodeRunner();
|
||
assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env');
|
||
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner });
|
||
const parsed = parseCodexHookBlock(block);
|
||
assert.equal(parsed.ok, true);
|
||
// Strict canonical-runner equality: the parsed runner (after stripping
|
||
// toml + JSON escape layers) must be exactly the normalized runner that
|
||
// resolveNodeRunner selected. Homebrew Cellar execPath values intentionally
|
||
// normalize to the stable Homebrew symlink (#3181).
|
||
const expected = JSON.parse(runner);
|
||
assert.equal(unescapeRunner(parsed.runner), expected,
|
||
`parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`);
|
||
});
|
||
});
|
||
|
||
describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => {
|
||
test('exported as a function', () => {
|
||
assert.equal(typeof rewriteLegacyCodexHookBlock, 'function');
|
||
});
|
||
|
||
test('rewrites a bare-node managed-hook command to the absolute runner', () => {
|
||
const before = [
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
'# GSD Hooks',
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "node /Users/x/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const expectedRunnerPath = '/usr/local/bin/node';
|
||
const runner = `"${expectedRunnerPath}"`;
|
||
const result = rewriteLegacyCodexHookBlock(before, runner);
|
||
assert.equal(result.changed, true, 'must report change=true');
|
||
// The migrated command must use the EXACT absolute runner the caller
|
||
// supplied (#3022 CR — was previously asserting a loose '/node'
|
||
// substring which let unrelated absolute paths pass).
|
||
const parsed = parseCodexHookBlock(result.content);
|
||
assert.equal(parsed.ok, true);
|
||
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
||
`runner must equal supplied absolute path: ${parsed.runner}`);
|
||
assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js');
|
||
// Non-GSD content (the [model] block) must be preserved verbatim.
|
||
assert.ok(result.content.includes('[model]'));
|
||
assert.ok(result.content.includes('name = "o3"'));
|
||
});
|
||
|
||
test('decodes TOML-escaped quoted script paths before projection', () => {
|
||
const before = [
|
||
'# GSD Hooks',
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""',
|
||
'',
|
||
].join('\n');
|
||
const runner = '"/usr/local/bin/node"';
|
||
const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' });
|
||
assert.equal(result.changed, true);
|
||
const parsed = parseCodexHookBlock(result.content);
|
||
assert.equal(parsed.ok, true, 'hook block must parse correctly');
|
||
const expected = projectCodexHookTomlCommand({
|
||
absoluteRunner: runner,
|
||
scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js',
|
||
platform: 'win32',
|
||
});
|
||
assert.equal(parsed.command, expected,
|
||
'rewritten command must project from decoded Windows path (not TOML-escaped token text)');
|
||
assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node',
|
||
'runner must equal supplied absolute path');
|
||
assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js',
|
||
'hook path must equal decoded Windows path after projection normalization');
|
||
});
|
||
|
||
test('does NOT touch a managed-hook entry that already uses an absolute runner', () => {
|
||
const already = [
|
||
'# GSD Hooks',
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"');
|
||
assert.equal(result.changed, false);
|
||
assert.equal(result.content, already);
|
||
});
|
||
|
||
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
|
||
const userOwned = [
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "node /home/me/my-custom-codex-hook.js"',
|
||
'',
|
||
].join('\n');
|
||
const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"');
|
||
assert.equal(result.changed, false,
|
||
'user-authored hooks must be left alone; only managed gsd-* hooks are migrated');
|
||
assert.equal(result.content, userOwned);
|
||
});
|
||
|
||
test('returns content unchanged when absoluteRunner is null', () => {
|
||
const before = 'command = "node /path/to/gsd-check-update.js"';
|
||
const result = rewriteLegacyCodexHookBlock(before, null);
|
||
assert.equal(result.changed, false);
|
||
assert.equal(result.content, before);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3018-codex-discuss-fallback.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3018-codex-discuss-fallback (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression test for bug #3018.
|
||
*
|
||
* @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where
|
||
* `request_user_input` is rejected) caused the agent to pick "reasonable
|
||
* defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md
|
||
* checkpoints — without ever surfacing the questions to the user. The
|
||
* generated Codex skill adapter explicitly told it to do that:
|
||
*
|
||
* "When `request_user_input` is rejected (Execute mode), present a
|
||
* plain-text numbered list and pick a reasonable default."
|
||
*
|
||
* Discuss-mode is the wrong place for that fallback. The contract should be:
|
||
* stop, render the questions as plain text, wait for the user's answer.
|
||
* Defaults may only be picked when the user has authorized non-interactive
|
||
* mode (--auto / --all) or has explicitly approved them.
|
||
*
|
||
* Test design (#3027 CR follow-up): instead of grepping the prose with
|
||
* regex, parse the fallback section into a typed semantic-flag record and
|
||
* assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep"
|
||
* — the test names a behavioral invariant, the parser walks the prose
|
||
* once and exposes the invariants as named flags, and the prose can be
|
||
* reworded freely as long as the flags stay true.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||
const { getCodexSkillAdapterHeader } = INSTALL;
|
||
const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||
|
||
/**
|
||
* Extract the "Execute mode fallback" section text from the adapter header.
|
||
* Returns null if the section is missing. Section runs from the
|
||
* "Execute mode fallback:" label up to the next heading or </codex_skill_adapter> tag.
|
||
*/
|
||
function extractExecuteModeFallback(header) {
|
||
const label = 'Execute mode fallback:';
|
||
const labelIdx = header.indexOf(label);
|
||
if (labelIdx === -1) return null;
|
||
const bodyStart = header.indexOf('\n', labelIdx + label.length);
|
||
if (bodyStart === -1) return null;
|
||
|
||
// End at whichever comes first: the next "## " heading (via the canonical
|
||
// heading tokenizer, not an ad-hoc regex) or the closing adapter tag.
|
||
const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart);
|
||
const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n
|
||
const closeTagIdx = header.indexOf('</codex_skill_adapter>', bodyStart);
|
||
const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n
|
||
const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset);
|
||
if (bodyEnd === Infinity) return null;
|
||
|
||
return header.slice(bodyStart + 1, bodyEnd).trim();
|
||
}
|
||
|
||
/**
|
||
* Parse the Execute-mode-fallback section into a typed semantic-flag
|
||
* record. Each flag answers a single behavioral question that the #3018
|
||
* fix is contractually required to encode in the prose. Tests assert on
|
||
* the booleans, not the wording — so the prose can evolve without test
|
||
* churn as long as the semantics stay correct.
|
||
*
|
||
* The flags are derived from a single pass over the section text: each
|
||
* one looks for any of a small set of synonym phrases that a correct
|
||
* implementation would use. The negative anti-pattern flag
|
||
* (`silentlyPicksDefaults`) is the regression guard — the prose under
|
||
* #3018 told the agent to "pick a reasonable default" autonomously,
|
||
* which is exactly what this fix removes.
|
||
*/
|
||
function parseExecuteModeFallback(section) {
|
||
if (!section || typeof section !== 'string') {
|
||
return {
|
||
ok: false,
|
||
sectionLength: 0,
|
||
instructsStop: false,
|
||
presentsPlainTextQuestions: false,
|
||
namesPermissionPath: false,
|
||
forbidsWritingArtifactsBeforeAnswer: false,
|
||
silentlyPicksDefaults: false,
|
||
};
|
||
}
|
||
const lower = section.toLowerCase();
|
||
// (a) STOP/WAIT directive — the agent must halt instead of proceeding.
|
||
const instructsStop = /\b(stop|halt|wait)\b/.test(lower);
|
||
// (b) Plain-text fallback presentation — the agent must surface the
|
||
// questions in some inspectable form (numbered list / plain text).
|
||
const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower);
|
||
// (c) Permission path that DOES allow defaults — must name at least
|
||
// one (--auto / --all / explicit user approval / autonomous workflow).
|
||
const namesPermissionPath =
|
||
/--auto|--all/.test(section) ||
|
||
/explicit(ly)? (approv|authoriz|consent)/i.test(section) ||
|
||
/user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) ||
|
||
/autonomous (lifecycle|workflow|paths?)/i.test(section);
|
||
// (d) Artifact-write ban — the agent must not produce workflow files
|
||
// (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the
|
||
// user answers or one of the permission-path conditions applies.
|
||
// Require BOTH a "do not write" intent AND a named artifact class so
|
||
// generic "do not write" prose elsewhere can't satisfy the flag.
|
||
const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section);
|
||
const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section);
|
||
const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass;
|
||
// Anti-pattern guard — the prose that caused #3018. This MUST be false.
|
||
const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section);
|
||
return {
|
||
ok: true,
|
||
sectionLength: section.length,
|
||
instructsStop,
|
||
presentsPlainTextQuestions,
|
||
namesPermissionPath,
|
||
forbidsWritingArtifactsBeforeAnswer,
|
||
silentlyPicksDefaults,
|
||
};
|
||
}
|
||
|
||
describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => {
|
||
test('exports the adapter generator', () => {
|
||
assert.equal(typeof getCodexSkillAdapterHeader, 'function');
|
||
});
|
||
|
||
test('Execute mode fallback section exists and has content', () => {
|
||
const header = getCodexSkillAdapterHeader('gsd-discuss-phase');
|
||
const section = extractExecuteModeFallback(header);
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`);
|
||
assert.ok(parsed.sectionLength > 0, 'section must be non-empty');
|
||
});
|
||
|
||
test('fallback instructs STOP/WAIT (not silent continuation)', () => {
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.instructsStop, true,
|
||
`must instruct stop/halt/wait — section was:\n${section}`);
|
||
});
|
||
|
||
test('fallback prescribes plain-text question presentation', () => {
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.presentsPlainTextQuestions, true,
|
||
`must mention plain-text / numbered-list presentation — section was:\n${section}`);
|
||
});
|
||
|
||
test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => {
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.namesPermissionPath, true,
|
||
`must name at least one permission path — section was:\n${section}`);
|
||
});
|
||
|
||
test('fallback forbids writing workflow artifacts before user answers', () => {
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true,
|
||
`must encode write-ban + named artifact class — section was:\n${section}`);
|
||
});
|
||
|
||
test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => {
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
assert.equal(parsed.silentlyPicksDefaults, false,
|
||
`regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`);
|
||
});
|
||
|
||
test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => {
|
||
// Single assertion that the whole semantic record matches the contract.
|
||
// If any flag flips, the test fails with a structured diff naming the
|
||
// exact invariant that broke.
|
||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||
const parsed = parseExecuteModeFallback(section);
|
||
const semanticContract = {
|
||
ok: parsed.ok,
|
||
instructsStop: parsed.instructsStop,
|
||
presentsPlainTextQuestions: parsed.presentsPlainTextQuestions,
|
||
namesPermissionPath: parsed.namesPermissionPath,
|
||
forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer,
|
||
silentlyPicksDefaults: parsed.silentlyPicksDefaults,
|
||
};
|
||
assert.deepStrictEqual(semanticContract, {
|
||
ok: true,
|
||
instructsStop: true,
|
||
presentsPlainTextQuestions: true,
|
||
namesPermissionPath: true,
|
||
forbidsWritingArtifactsBeforeAnswer: true,
|
||
silentlyPicksDefaults: false,
|
||
}, `discuss-mode fallback contract violated — section was:\n${section}`);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3245-codex-toml-floats.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3245-codex-toml-floats (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression: issue #3245 — Codex install rejects valid TOML floats.
|
||
*
|
||
* Two defects, two fixes:
|
||
*
|
||
* Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0).
|
||
* Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec
|
||
* (integers fail with "invalid type: integer"). GSD's strict-integer-only parser
|
||
* was the inverse of what Codex requires — any float triggers the rejection branch.
|
||
* Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as
|
||
* JS Number. The merged config.toml preserves the float form verbatim so
|
||
* round-trip writes don't coerce 20.0 → 20.
|
||
*
|
||
* Defect 2 — Partial rollback leaves install in hybrid state.
|
||
* restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION
|
||
* are written earlier in the install sequence. A post-install validation failure
|
||
* aborts with new agent text on disk, config.toml reverted, and .tmp files
|
||
* potentially orphaned.
|
||
* Fix: capture pre-install state of skills/, agents/, and VERSION before any
|
||
* Codex-specific mutation, and extend the rollback to cover all of them.
|
||
*/
|
||
|
||
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
||
// skips the main CLI entry point and exports its internals.
|
||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js');
|
||
const installModule = require('../bin/install.js');
|
||
|
||
if (previousGsdTestMode === undefined) {
|
||
delete process.env.GSD_TEST_MODE;
|
||
} else {
|
||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||
}
|
||
|
||
// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.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');
|
||
before(() => {
|
||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||
throwIfFailed(
|
||
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
||
`node ${BUILD_HOOKS_SCRIPT}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
function runCodexInstall(codexHome) {
|
||
const previousCodexHome = process.env.CODEX_HOME;
|
||
const previousCwd = process.cwd();
|
||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to codexHome so this
|
||
// in-process install never materializes skills under the developer/CI
|
||
// machine's real home directory.
|
||
const previousHome = process.env.HOME;
|
||
const previousUserProfile = process.env.USERPROFILE;
|
||
process.env.CODEX_HOME = codexHome;
|
||
process.env.HOME = codexHome;
|
||
process.env.USERPROFILE = codexHome;
|
||
try {
|
||
process.chdir(path.join(__dirname, '..'));
|
||
return install(true, 'codex');
|
||
} finally {
|
||
process.chdir(previousCwd);
|
||
if (previousCodexHome === undefined) {
|
||
delete process.env.CODEX_HOME;
|
||
} else {
|
||
process.env.CODEX_HOME = previousCodexHome;
|
||
}
|
||
if (previousHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = previousHome;
|
||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = previousUserProfile;
|
||
}
|
||
}
|
||
|
||
function writeCodexConfig(codexHome, content) {
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defect 1 — parseTomlValue must accept TOML floats
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('#3245 — parseTomlToObject accepts TOML floats', () => {
|
||
test('parses bare decimal float (20.0)', () => {
|
||
const content = [
|
||
'tool_timeout_sec = 20.0',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(typeof parsed.tool_timeout_sec, 'number',
|
||
'tool_timeout_sec should be a JS number');
|
||
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
||
'value must equal 20.0');
|
||
});
|
||
|
||
test('parses startup_timeout_sec = 60.0', () => {
|
||
const content = [
|
||
'startup_timeout_sec = 60.0',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.startup_timeout_sec, 60.0);
|
||
});
|
||
|
||
test('parses positive exponent notation (1e10)', () => {
|
||
const content = [
|
||
'x = 1e10',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.x, 1e10);
|
||
});
|
||
|
||
test('parses negative exponent (1.5e-3)', () => {
|
||
const content = [
|
||
'x = 1.5e-3',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3');
|
||
});
|
||
|
||
test('parses signed positive float (+1.0)', () => {
|
||
const content = [
|
||
'x = +1.0',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.x, 1.0);
|
||
});
|
||
|
||
test('parses signed negative float (-0.5)', () => {
|
||
const content = [
|
||
'x = -0.5',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.x, -0.5);
|
||
});
|
||
|
||
test('parses float with underscore separators (1_000.0)', () => {
|
||
const content = [
|
||
'x = 1_000.0',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.x, 1000.0);
|
||
});
|
||
|
||
test('integer (no decimal) still parses as integer', () => {
|
||
const content = [
|
||
'x = 42',
|
||
'',
|
||
].join('\n');
|
||
const parsed = parseTomlToObject(content);
|
||
assert.strictEqual(parsed.x, 42);
|
||
});
|
||
|
||
test('still rejects bare date (1979-05-27)', () => {
|
||
const content = [
|
||
'x = 1979-05-27',
|
||
'',
|
||
].join('\n');
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/unsupported TOML value/,
|
||
'date literals must remain unsupported'
|
||
);
|
||
});
|
||
|
||
test('still rejects bare time (07:32:00)', () => {
|
||
const content = [
|
||
'x = 07:32:00',
|
||
'',
|
||
].join('\n');
|
||
// With leading-zero rejection (CR4 fix) the parser stops at `0`, and
|
||
// `7:32:00` is "trailing bytes". Either error form is acceptable — the
|
||
// key invariant is that time literals are never silently accepted.
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/unsupported TOML value|trailing bytes/,
|
||
'time literals must remain unsupported'
|
||
);
|
||
});
|
||
|
||
test('still rejects hex literal (0x1A)', () => {
|
||
const content = [
|
||
'x = 0x1A',
|
||
'',
|
||
].join('\n');
|
||
// 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes"
|
||
// or "unsupported value" depending on where the parser catches it.
|
||
assert.throws(
|
||
() => parseTomlToObject(content),
|
||
/trailing bytes|unsupported (TOML value|value)/,
|
||
'hex literals must remain unsupported'
|
||
);
|
||
});
|
||
|
||
test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => {
|
||
const content = [
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
'tool_timeout_sec = 20.0',
|
||
'startup_timeout_sec = 60.0',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'schema validation must pass for a config containing TOML floats: ' + result.reason);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defect 1 — full install must succeed and preserve float verbatim
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// concurrency: false — drives the live install pipeline (shared CODEX_HOME env,
|
||
// process.chdir). Serialise to prevent stray mutations across parallel siblings.
|
||
describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('install completes when config.toml contains tool_timeout_sec = 20.0', () => {
|
||
// Floats at the root level (before any table header) — this is where Codex
|
||
// CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema.
|
||
const preInstall = [
|
||
'tool_timeout_sec = 20.0',
|
||
'startup_timeout_sec = 60.0',
|
||
'',
|
||
'[model]',
|
||
'name = "o3"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
// Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported".
|
||
assert.doesNotThrow(
|
||
() => runCodexInstall(codexHome),
|
||
'install must not throw when config.toml contains TOML floats'
|
||
);
|
||
|
||
// The merged config.toml must still contain the float values at root scope.
|
||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
const parsed = parseTomlToObject(after);
|
||
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
||
'tool_timeout_sec must be preserved as a number after install');
|
||
assert.strictEqual(parsed.startup_timeout_sec, 60.0,
|
||
'startup_timeout_sec must be preserved as a number after install');
|
||
});
|
||
|
||
test('post-install config round-trips tool_timeout_sec as numeric 20', () => {
|
||
const preInstall = [
|
||
'tool_timeout_sec = 20.0',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(codexHome, preInstall);
|
||
|
||
runCodexInstall(codexHome);
|
||
|
||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
// The value must survive round-trip as a float-compatible representation.
|
||
// Parse structurally — don't grep for the literal string "20.0".
|
||
const parsed = parseTomlToObject(after);
|
||
assert.strictEqual(parsed.tool_timeout_sec, 20,
|
||
'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow
|
||
// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0`
|
||
// are therefore invalid. The `parseTomlValue` integer-part regex is tightened
|
||
// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`.
|
||
|
||
describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => {
|
||
function parseValue(raw) {
|
||
// Wrap in a minimal TOML assignment so parseTomlToObject drives the test.
|
||
return parseTomlToObject(`x = ${raw}`).x;
|
||
}
|
||
|
||
function assertRejects(raw, label) {
|
||
let threw = false;
|
||
try { parseValue(raw); } catch (_) { threw = true; }
|
||
assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`);
|
||
}
|
||
|
||
function assertAccepts(raw, expected, label) {
|
||
let val;
|
||
let threw = false;
|
||
try { val = parseValue(raw); } catch (e) { threw = true; }
|
||
assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`);
|
||
if (expected !== undefined) {
|
||
assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`);
|
||
}
|
||
}
|
||
|
||
// --- rejection cases: leading zeros in the integer part ---
|
||
|
||
test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01'));
|
||
test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00'));
|
||
test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5'));
|
||
test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5'));
|
||
test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01'));
|
||
test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01'));
|
||
test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2'));
|
||
test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0'));
|
||
test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0'));
|
||
test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10'));
|
||
|
||
// --- acceptance cases: valid TOML 1.0 numeric forms ---
|
||
|
||
test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero'));
|
||
test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal'));
|
||
test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero'));
|
||
test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1'));
|
||
test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5'));
|
||
test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5'));
|
||
test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1'));
|
||
test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12'));
|
||
test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5'));
|
||
test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000'));
|
||
test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5'));
|
||
test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5'));
|
||
test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0'));
|
||
test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3'));
|
||
test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10'));
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defect 2 — idempotent rollback covers skills, agents, VERSION
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// concurrency: false — patches module.exports.__codexSchemaValidator and drives
|
||
// the install pipeline. Serialise to prevent cross-test pollution.
|
||
describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete installModule.__codexSchemaValidator;
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => {
|
||
// Start from a clean codexHome with no pre-existing GSD content — the dirs
|
||
// do not exist yet. After a failed install they must be absent (or contain
|
||
// only what was there before, i.e. nothing).
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
|
||
// Force schema validation to fail so we can observe the rollback without
|
||
// needing a genuinely broken config.
|
||
installModule.__codexSchemaValidator = () => ({
|
||
ok: false,
|
||
reason: 'simulated failure for #3245 rollback test',
|
||
});
|
||
|
||
let threw = false;
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (_) {
|
||
threw = true;
|
||
}
|
||
assert.strictEqual(threw, true, 'install must throw when validation fails');
|
||
|
||
// skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback.
|
||
const skillsDir = codexSkillsRoot(codexHome);
|
||
if (fs.existsSync(skillsDir)) {
|
||
const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||
assert.strictEqual(
|
||
gsdSkills.length,
|
||
0,
|
||
'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ')
|
||
);
|
||
}
|
||
|
||
// agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent.
|
||
// Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest
|
||
// dir and is .toml-inclusive, so its semantics differ from the source roster.
|
||
const agentsDir = path.join(codexHome, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const gsdAgents = fs.readdirSync(agentsDir)
|
||
.filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')));
|
||
assert.strictEqual(
|
||
gsdAgents.length,
|
||
0,
|
||
'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ')
|
||
);
|
||
}
|
||
|
||
// VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before).
|
||
const versionPath = path.join(codexHome, 'gsd-core', 'VERSION');
|
||
assert.strictEqual(
|
||
fs.existsSync(versionPath),
|
||
false,
|
||
'rollback must remove the VERSION file written during install'
|
||
);
|
||
});
|
||
|
||
test('rollback is safe when fired before any snapshots were captured (very early failure)', () => {
|
||
// If the validator is injected before ANY install writes happen, the rollback
|
||
// must not throw — it should be idempotent when nothing was written yet.
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
|
||
installModule.__codexSchemaValidator = () => ({
|
||
ok: false,
|
||
reason: 'very early simulated failure',
|
||
});
|
||
|
||
// The install must throw (validation failure), but the rollback that runs
|
||
// internally must not throw — it must be idempotent when nothing was written.
|
||
let threw = false;
|
||
try {
|
||
runCodexInstall(codexHome);
|
||
} catch (_) {
|
||
threw = true;
|
||
}
|
||
assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)');
|
||
// Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was
|
||
// created during the install, no gsd-* dirs should survive after rollback.
|
||
const skillsDir = codexSkillsRoot(codexHome);
|
||
const remainingGsdSkills = fs.existsSync(skillsDir)
|
||
? fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
|
||
.map((e) => e.name)
|
||
: [];
|
||
assert.deepStrictEqual(
|
||
remainingGsdSkills,
|
||
[],
|
||
'rollback must remove all gsd-* skill dirs even when fired after minimal writes'
|
||
);
|
||
});
|
||
|
||
test('rollback does not remove pre-existing user skills that GSD did not write', () => {
|
||
// If the user has a custom skill dir (not gsd-*) it must survive rollback.
|
||
const skillsDir = codexSkillsRoot(codexHome);
|
||
const userSkill = path.join(skillsDir, 'my-custom-skill');
|
||
fs.mkdirSync(userSkill, { recursive: true });
|
||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8');
|
||
|
||
installModule.__codexSchemaValidator = () => ({
|
||
ok: false,
|
||
reason: 'simulated failure — user skill must survive',
|
||
});
|
||
|
||
let threw = false;
|
||
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
||
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)');
|
||
|
||
assert.strictEqual(
|
||
fs.existsSync(path.join(userSkill, 'SKILL.md')),
|
||
true,
|
||
'pre-existing non-gsd-* skill must survive rollback'
|
||
);
|
||
});
|
||
|
||
test('rollback removes orphaned atomic-write temp files', () => {
|
||
// Any <file>.tmp-<pid>-<n> files created during aborted atomic writes
|
||
// must be cleaned up by the rollback so targetDir is not left with stray
|
||
// temp files consuming disk space.
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
|
||
installModule.__codexSchemaValidator = () => ({
|
||
ok: false,
|
||
reason: 'simulated failure for temp-file cleanup test',
|
||
});
|
||
|
||
let threw = false;
|
||
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
||
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)');
|
||
|
||
// Scan for any *.tmp-* files left in codexHome after rollback.
|
||
const tmpPattern = /\.tmp-\d+-\d+$/;
|
||
function findTmpFiles(dir) {
|
||
if (!fs.existsSync(dir)) return [];
|
||
const results = [];
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
results.push(...findTmpFiles(full));
|
||
} else if (tmpPattern.test(entry.name)) {
|
||
results.push(full);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
const stray = findTmpFiles(codexHome);
|
||
assert.strictEqual(
|
||
stray.length,
|
||
0,
|
||
'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ')
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3285-codex-hooks-state-allowed.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3285-codex-hooks-state-allowed (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression: issue #3285 — Codex install fails when config.toml contains
|
||
* hooks.state entries.
|
||
*
|
||
* Root cause: validateCodexConfigSchema walks every `hooks.*` table section
|
||
* and asserts array-of-tables (AoT) shape, without distinguishing the
|
||
* `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a
|
||
* regular table) from `hooks.<EVENT>` (event handlers like SessionStart,
|
||
* which DO require AoT shape via [[hooks.SessionStart]]).
|
||
*
|
||
* Fix: add a carve-out so that any table whose path starts with `hooks.state`
|
||
* is validated as a regular table (not AoT). All `hooks.<EVENT>` paths still
|
||
* require AoT.
|
||
*/
|
||
|
||
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
||
// skips the main CLI entry point and exports its internals.
|
||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
|
||
const { validateCodexConfigSchema, install } = require('../bin/install.js');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
if (previousGsdTestMode === undefined) {
|
||
delete process.env.GSD_TEST_MODE;
|
||
} else {
|
||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||
}
|
||
|
||
// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs.
|
||
const { before, beforeEach, afterEach } = require('node:test');
|
||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
||
// syntax-checks them with vm — it does not compile/bundle anything. See
|
||
// tests/helpers/timeouts.cjs for the class-norm justification.
|
||
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||
before(() => {
|
||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||
throwIfFailed(
|
||
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
||
`node ${BUILD_HOOKS_SCRIPT}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validator unit tests (no install, just validateCodexConfigSchema)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => {
|
||
test('bare [hooks.state] table header passes validation', () => {
|
||
const content = [
|
||
'[hooks.state]',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason);
|
||
});
|
||
|
||
test('bare [hooks.state.<project-key>] table header passes validation', () => {
|
||
// Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries.
|
||
// The key contains slashes and colons — must be quoted in TOML.
|
||
const content = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:abc123"',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'bare [hooks.state.<key>] with trust fields must be allowed: ' + result.reason);
|
||
});
|
||
|
||
test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => {
|
||
// The real-world fixture: user has both Codex trust state AND GSD-managed
|
||
// event hooks in the same config.toml.
|
||
const content = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:abc123"',
|
||
'',
|
||
'[[hooks.SessionStart]]',
|
||
'',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "/usr/local/bin/gsd-check-update"',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason);
|
||
});
|
||
|
||
test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => {
|
||
// Regression guard: the fix must NOT relax AoT requirements for event hooks.
|
||
// [hooks.SessionStart] (single-bracket) must still fail.
|
||
const content = [
|
||
'[hooks.SessionStart]',
|
||
'type = "command"',
|
||
'command = "/some/command"',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, false,
|
||
'[hooks.SessionStart] bare table (not AoT) must still be rejected');
|
||
assert.ok(
|
||
result.reason.includes('hooks.SessionStart'),
|
||
'rejection reason must mention hooks.SessionStart, got: ' + result.reason
|
||
);
|
||
});
|
||
|
||
test('hooks.state object in parsed structure does not trigger non-array rejection', () => {
|
||
// The parsed-object check loops over Object.entries(parsed.hooks) and
|
||
// asserts !Array.isArray(value) → error. hooks.state is an object, not
|
||
// an array. The fix must skip hooks.state in that loop too.
|
||
const content = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'some-key']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:deadbeef"',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason);
|
||
});
|
||
|
||
test('multiple hooks.state sub-keys all pass validation', () => {
|
||
const content = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:aaa"',
|
||
'',
|
||
"[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = false',
|
||
'trusted_hash = "sha256:bbb"',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, true,
|
||
'multiple hooks.state sub-keys must all pass: ' + result.reason);
|
||
});
|
||
|
||
test('[[hooks.state]] AoT form is rejected', () => {
|
||
// hooks.state must be a regular table — array-of-tables shape is invalid.
|
||
const content = [
|
||
'[[hooks.state]]',
|
||
'enabled = true',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, false,
|
||
'[[hooks.state]] (AoT) must be rejected');
|
||
assert.ok(
|
||
result.reason.includes('hooks.state'),
|
||
'rejection reason must mention hooks.state, got: ' + result.reason
|
||
);
|
||
});
|
||
|
||
test('[[hooks.state.foo]] AoT sub-key form is rejected', () => {
|
||
// hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid.
|
||
const content = [
|
||
'[[hooks.state.foo]]',
|
||
'enabled = true',
|
||
'',
|
||
].join('\n');
|
||
const result = validateCodexConfigSchema(content);
|
||
assert.strictEqual(result.ok, false,
|
||
'[[hooks.state.foo]] (AoT sub-key) must be rejected');
|
||
assert.ok(
|
||
result.reason.includes('hooks.state'),
|
||
'rejection reason must mention hooks.state, got: ' + result.reason
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Full install integration test
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => {
|
||
let tmpDir;
|
||
let codexHome;
|
||
|
||
function writeCodexConfig(content) {
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
||
}
|
||
|
||
function runCodexInstall() {
|
||
const previousCodexHome = process.env.CODEX_HOME;
|
||
const previousCwd = process.cwd();
|
||
// #2088 (ADR-1239 upgrade 3): Codex skills now install to the canonical
|
||
// $HOME/.agents/skills root (os.homedir()-relative, independent of
|
||
// CODEX_HOME). Sandbox HOME (and USERPROFILE) to tmpDir so this
|
||
// in-process install never materializes skills under the developer/CI
|
||
// machine's real home directory.
|
||
const previousHome = process.env.HOME;
|
||
const previousUserProfile = process.env.USERPROFILE;
|
||
process.env.CODEX_HOME = codexHome;
|
||
process.env.HOME = tmpDir;
|
||
process.env.USERPROFILE = tmpDir;
|
||
try {
|
||
process.chdir(path.join(__dirname, '..'));
|
||
return install(true, 'codex');
|
||
} finally {
|
||
process.chdir(previousCwd);
|
||
if (previousCodexHome === undefined) {
|
||
delete process.env.CODEX_HOME;
|
||
} else {
|
||
process.env.CODEX_HOME = previousCodexHome;
|
||
}
|
||
if (previousHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = previousHome;
|
||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = previousUserProfile;
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-'));
|
||
codexHome = path.join(tmpDir, 'codex-home');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('install does not throw when config.toml contains hooks.state trust entries', () => {
|
||
// This is the exact failure scenario reported in #3285.
|
||
const preInstall = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:abc123def456"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(preInstall);
|
||
|
||
assert.doesNotThrow(
|
||
() => runCodexInstall(),
|
||
'install must not throw when config.toml contains hooks.state trust entries'
|
||
);
|
||
});
|
||
|
||
test('hooks.state entries are preserved in post-install config.toml', () => {
|
||
const preInstall = [
|
||
'[hooks.state]',
|
||
'',
|
||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||
'enabled = true',
|
||
'trusted_hash = "sha256:abc123def456"',
|
||
'',
|
||
].join('\n');
|
||
writeCodexConfig(preInstall);
|
||
|
||
runCodexInstall();
|
||
|
||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||
// Verify structurally: the trust hash key must survive the install.
|
||
// Do NOT grep for the literal string — parse the TOML structure.
|
||
const { parseTomlToObject } = require('../bin/install.js');
|
||
const parsed = parseTomlToObject(after);
|
||
assert.ok(
|
||
parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null,
|
||
'post-install config.toml must have hooks.state as an object'
|
||
);
|
||
// Verify the actual trust entry survives — not just that hooks.state is an object.
|
||
const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0";
|
||
assert.ok(
|
||
parsed.hooks.state[trustKey] != null,
|
||
`post-install must preserve the original trust entry for key: ${trustKey}`
|
||
);
|
||
assert.strictEqual(
|
||
parsed.hooks.state[trustKey].enabled,
|
||
true,
|
||
'preserved trust entry must have enabled = true'
|
||
);
|
||
assert.strictEqual(
|
||
parsed.hooks.state[trustKey].trusted_hash,
|
||
'sha256:abc123def456',
|
||
'preserved trust entry must have the original trusted_hash'
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3346-codex-aot-toml-key.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3346-codex-aot-toml-key (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression: issue #3346 — Codex install fails on Windows when the legacy
|
||
* Codex `[hooks]` config uses a `<file>:<event>:<line>:<col>` location tuple
|
||
* as the table key (with the actual event name carried in an `event = "..."`
|
||
* body field). `migrateCodexHooksMapFormat` re-emitted the location tuple
|
||
* verbatim as the leaf TOML key, producing a header like
|
||
*
|
||
* [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]]
|
||
*
|
||
* which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to
|
||
* be the event name, not a diagnostic location identifier).
|
||
*
|
||
* Expected behaviour: when the legacy `[hooks.<X>]` body declares an
|
||
* `event = "..."` field, the migrator must use that event name as the leaf
|
||
* TOML key for the emitted `[[hooks.<EVENT>]]` two-level nested AoT block.
|
||
*
|
||
* Test discipline: parse the migrated TOML with the project's own
|
||
* `parseTomlToObject` and assert on the resulting object shape — never
|
||
* grep the raw string.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const {
|
||
migrateCodexHooksMapFormat,
|
||
parseTomlToObject,
|
||
} = require('../bin/install.js');
|
||
|
||
describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => {
|
||
test('legacy [hooks."<location-tuple>"] with event="..." body migrates to [[hooks.<event>]]', () => {
|
||
// Pre-install fixture: a legacy `[hooks.<quoted-key>]` block whose key is
|
||
// a `<config-path>:<event>:<line>:<col>` location identifier. The actual
|
||
// event name lives in the body as `event = "session_start"`.
|
||
const legacy = [
|
||
'[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]',
|
||
'event = "session_start"',
|
||
'command = "echo hi"',
|
||
'',
|
||
].join('\n');
|
||
|
||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||
const parsed = parseTomlToObject(migrated);
|
||
|
||
// The migrated hooks object must be keyed by the event name, not by the
|
||
// location tuple. This is the core assertion of #3346.
|
||
assert.ok(parsed.hooks, 'migrated TOML must define a hooks table');
|
||
assert.deepEqual(
|
||
Object.keys(parsed.hooks),
|
||
['session_start'],
|
||
`migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}`
|
||
);
|
||
|
||
// The handler body must survive the migration and live under the two-level
|
||
// nested AoT shape (hooks.<event>[0].hooks[0].command).
|
||
const eventEntries = parsed.hooks.session_start;
|
||
assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1,
|
||
'hooks.session_start must be an array of tables');
|
||
const handlers = eventEntries[0].hooks;
|
||
assert.ok(Array.isArray(handlers) && handlers.length >= 1,
|
||
'hooks.session_start[0].hooks must be an array of handler tables');
|
||
assert.equal(handlers[0].command, 'echo hi',
|
||
'handler command must be preserved through migration');
|
||
assert.equal(handlers[0].type, 'command',
|
||
'handler type must default to "command" when no explicit type given');
|
||
assert.equal(handlers[0].event, undefined,
|
||
'handler body must not retain legacy `event` field after migration');
|
||
});
|
||
|
||
test('legacy [hooks."<location>"] with explicit type and event survives migration cleanly', () => {
|
||
// Same as above but with an explicit `type` field — the migrator must not
|
||
// duplicate it when re-emitting the handler.
|
||
const legacy = [
|
||
'[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]',
|
||
'event = "tool_call_pre"',
|
||
'type = "command"',
|
||
'command = "node /path/to/hook.js"',
|
||
'',
|
||
].join('\n');
|
||
|
||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||
const parsed = parseTomlToObject(migrated);
|
||
|
||
assert.deepEqual(
|
||
Object.keys(parsed.hooks),
|
||
['tool_call_pre'],
|
||
'leaf key must be the event name from the `event = "..."` body field'
|
||
);
|
||
const handler = parsed.hooks.tool_call_pre[0].hooks[0];
|
||
assert.equal(handler.command, 'node /path/to/hook.js');
|
||
assert.equal(handler.type, 'command');
|
||
assert.equal(handler.event, undefined,
|
||
'handler body must not retain legacy `event` field after migration');
|
||
});
|
||
|
||
test('legacy [hooks.<bare-event>] without location-tuple key continues to work unchanged', () => {
|
||
// Regression guard: the fix must not break the canonical legacy-map case
|
||
// ([hooks.<event-name>] with handler-fields-only body, no `event` key).
|
||
const legacy = [
|
||
'[hooks.session_start]',
|
||
'command = "echo hi"',
|
||
'',
|
||
].join('\n');
|
||
|
||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||
const parsed = parseTomlToObject(migrated);
|
||
|
||
assert.deepEqual(
|
||
Object.keys(parsed.hooks),
|
||
['session_start'],
|
||
'bare-event legacy shape must continue to migrate to event-named leaf key'
|
||
);
|
||
assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3360-codex-execute-phase-worktrees.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3360-codex-execute-phase-worktrees (consolidation epic #1969 B1 #1970)", () => {
|
||
/**
|
||
* Regression test for bug #3360.
|
||
*
|
||
* Codex does not have a direct equivalent of Claude Code's
|
||
* `Agent(... isolation="worktree")`. The execute-phase workflow must fail
|
||
* closed for Codex + workflow.use_worktrees=true instead of spawning
|
||
* workspace-write executors in the main checkout.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||
const { getCodexSkillAdapterHeader } = require('../bin/install.js');
|
||
|
||
function parseWorkflowSteps(content) {
|
||
return [...content.matchAll(/<step name="([^"]+)"[^>]*>([\s\S]*?)<\/step>/g)]
|
||
.map((match) => {
|
||
const body = match[2];
|
||
return {
|
||
name: match[1],
|
||
// After #3797 architectural fix, callsites use gsd_run
|
||
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
|
||
// #1521 generalized the guard from Codex-specific to all non-Claude
|
||
// runtimes; #2584 Phase 3 (#2627) generalized it again — off runtime
|
||
// identity entirely and onto the negotiated `dispatch.isolation`
|
||
// capability. The step now resolves ISOLATION (delegating the block to
|
||
// the isolation-dispatch fragment) and fails closed when a host
|
||
// declares no primitive, which is what #3360 actually protects.
|
||
resolvesIsolationCapability: body.includes('Resolve ISOLATION'),
|
||
// Worktree dispatch guidance is no longer a hardcoded Claude flag —
|
||
// step 3 emits the host's DECLARED harness flag.
|
||
worktreeDispatchGuidance: body.includes('{harnessFlag}')
|
||
|| body.includes('executor-isolation-dispatch.md'),
|
||
};
|
||
});
|
||
}
|
||
|
||
function executePhaseWorktreeContract(content) {
|
||
const steps = parseWorkflowSteps(content);
|
||
const initializeIndex = steps.findIndex((step) => step.name === 'initialize');
|
||
const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance);
|
||
assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step');
|
||
assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance');
|
||
|
||
const initialize = steps[initializeIndex];
|
||
return {
|
||
initializeReadsRuntimeConfig: initialize.readsRuntimeConfig,
|
||
initializeResolvesIsolationCapability: initialize.resolvesIsolationCapability,
|
||
guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex,
|
||
};
|
||
}
|
||
|
||
describe('#3360 — execute-phase fails closed for unsupported worktree isolation', () => {
|
||
// #2584 Phase 3 (#2627) moved this from "Codex is blocked by name" to "a host
|
||
// with no declared isolation primitive is blocked". Codex now DECLARES
|
||
// orchestrator-worktree and gets a real isolated path, so the guard can no
|
||
// longer key on its name — but #3360's actual protection (never run executors
|
||
// unisolated against the main checkout) is unchanged and asserted below.
|
||
const ISOLATION_FRAGMENT = path.join(
|
||
ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'executor-isolation-dispatch.md',
|
||
);
|
||
|
||
test('execute-phase resolves the isolation capability before any worktree dispatch', () => {
|
||
const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
||
const contract = executePhaseWorktreeContract(workflow);
|
||
|
||
assert.deepEqual(contract, {
|
||
initializeReadsRuntimeConfig: true,
|
||
initializeResolvesIsolationCapability: true,
|
||
guardStepPrecedesWorktreeDispatch: true,
|
||
});
|
||
});
|
||
|
||
test('a host declaring no isolation primitive still fails closed', () => {
|
||
const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8');
|
||
assert.match(fragment, /ISOLATION="?none"?/,
|
||
'fragment must resolve the none case');
|
||
assert.match(fragment, /FATAL[^\n]*no executor-isolation primitive/,
|
||
'a host with dispatch.isolation=none must fail closed before dispatch (#3360)');
|
||
assert.match(fragment, /use_worktrees=false/,
|
||
'the fail-closed message must tell the user how to proceed');
|
||
});
|
||
|
||
test('the scheduler never gates worktree dispatch on a runtime name', () => {
|
||
const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
||
const fragment = fs.readFileSync(ISOLATION_FRAGMENT, 'utf8');
|
||
for (const [label, src] of [['execute-phase.md', workflow], ['isolation fragment', fragment]]) {
|
||
assert.ok(
|
||
!/\[\s*"\$RUNTIME"\s*(?:!=|=)\s*"(?:codex|claude)"\s*\]\s*&&\s*\[\s*"\$USE_WORKTREES"/.test(src),
|
||
`${label}: worktree dispatch must branch on dispatch.isolation, not a runtime name (ADR-1239)`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('Codex adapter documents the orchestrator-managed worktree mapping', () => {
|
||
const header = getCodexSkillAdapterHeader('gsd-execute-phase');
|
||
assert.match(header, /isolation="worktree"/);
|
||
assert.match(header, /orchestrator-worktree/i,
|
||
'the adapter header must no longer claim Codex has no worktree mapping — #2584 Phase 3 gave it one');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3426-codex-windows-hooks.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3426-codex-windows-hooks (consolidation epic #1969 B1 #1970)", () => {
|
||
'use strict';
|
||
|
||
/**
|
||
* Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1
|
||
*
|
||
* After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure
|
||
* mode appeared on v1.42.3+:
|
||
*
|
||
* Failed with non-blocking status code:
|
||
* C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file
|
||
*
|
||
* Root cause: Codex on Windows runs hook commands from a PowerShell/cmd
|
||
* execution environment (see install.js comment at buildHookCommand). The
|
||
* command string written to hooks.json was:
|
||
*
|
||
* "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
|
||
*
|
||
* When Codex's hook runner passes this to its subprocess spawner, the quoted
|
||
* path resolves through Git Bash (MSYS), which then tries to POSIX-exec
|
||
* node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec
|
||
* path calls execvp() on the PE binary directly, which fails with ENOEXEC,
|
||
* reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix
|
||
* appears because the error propagates through the bash.exe process that Codex
|
||
* uses as its hook-dispatch shell.
|
||
*
|
||
* Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple
|
||
* IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command.
|
||
* cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer,
|
||
* no MSYS shebang walk.
|
||
*
|
||
* Test strategy:
|
||
* - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on
|
||
* rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline).
|
||
* - Counter-tests confirm darwin/linux paths are unchanged.
|
||
*
|
||
* NOTE: Windows wall-clock verification depends on Docker matrix Windows
|
||
* runners. Local test exercises the generator IR shape only.
|
||
*/
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const INSTALL = require('../bin/install.js');
|
||
const HOOKS_SURFACE = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
||
const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
|
||
const {
|
||
uninstall,
|
||
} = INSTALL;
|
||
|
||
const {
|
||
buildCodexHookWindowsShimIR,
|
||
ensureCodexHooksJsonSessionStart,
|
||
resolveNodeRunner,
|
||
} = HOOKS_SURFACE;
|
||
|
||
const { projectManagedHookCommand } = PROJECTION;
|
||
|
||
/**
|
||
* Extract hook handler objects for `eventName` from a hooks.json object.
|
||
* Handles both the legacy top-level shape { SessionStart: [...] } and the
|
||
* canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348).
|
||
*/
|
||
function hookHandlersForEvent(hooksJson, eventName) {
|
||
if (!hooksJson || typeof hooksJson !== 'object') return [];
|
||
const table =
|
||
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks)
|
||
? hooksJson.hooks
|
||
: hooksJson;
|
||
if (!Array.isArray(table[eventName])) return [];
|
||
return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []);
|
||
}
|
||
|
||
// ─── Step 1: Export surface check ────────────────────────────────────────────
|
||
|
||
describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => {
|
||
test('buildCodexHookWindowsShimIR is a function', () => {
|
||
assert.equal(typeof buildCodexHookWindowsShimIR, 'function',
|
||
'buildCodexHookWindowsShimIR must be exported from runtime-hooks-surface.cjs');
|
||
});
|
||
|
||
test('ensureCodexHooksJsonSessionStart is a function', () => {
|
||
assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function',
|
||
'ensureCodexHooksJsonSessionStart must be exported from runtime-hooks-surface.cjs');
|
||
});
|
||
});
|
||
|
||
// ─── Step 2: Typed IR shape for Windows Codex hook shim ──────────────────────
|
||
|
||
describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => {
|
||
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
||
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
||
|
||
test('returns typed IR with invocation, cmdPath, and render factory', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
// IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline
|
||
assert.ok(ir && typeof ir === 'object', 'must return an object');
|
||
assert.ok(typeof ir.invocation === 'object', 'must have invocation record');
|
||
assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string');
|
||
assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)');
|
||
assert.ok(typeof ir.render === 'object', 'must have render factory');
|
||
assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory');
|
||
});
|
||
|
||
test('invocation.target equals the resolved script path', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
// invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple
|
||
assert.ok(
|
||
ir.invocation.target.includes('gsd-check-update.js'),
|
||
`invocation.target must reference the hook script, got: ${ir.invocation.target}`,
|
||
);
|
||
});
|
||
|
||
test('invocation.interpreter is the node runner (not bash)', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
// The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows
|
||
const interp = ir.invocation.interpreter;
|
||
assert.ok(
|
||
typeof interp === 'string' && (interp.includes('node') || interp === 'node'),
|
||
`invocation.interpreter must be a node path, not bash. Got: ${interp}`,
|
||
);
|
||
assert.ok(
|
||
!interp.toLowerCase().includes('bash'),
|
||
`invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`,
|
||
);
|
||
});
|
||
|
||
test('cmdPath ends with .cmd extension', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
assert.ok(
|
||
ir.cmdPath.endsWith('.cmd'),
|
||
`cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`,
|
||
);
|
||
});
|
||
|
||
test('hookCommand is the .cmd path (not a "runner script.js" string)', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
// The hook command written to hooks.json must be the .cmd path, not "node.exe script.js"
|
||
// because cmd.exe executes .cmd natively without POSIX exec layer
|
||
assert.ok(
|
||
ir.hookCommand.includes('.cmd'),
|
||
`hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`,
|
||
);
|
||
// hookCommand must NOT contain bash — this was the failure mode
|
||
assert.ok(
|
||
!ir.hookCommand.toLowerCase().includes('bash'),
|
||
`hookCommand must NOT reference bash, got: ${ir.hookCommand}`,
|
||
);
|
||
});
|
||
|
||
test('returns null when absoluteRunnerToken is null (caller skips registration)', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null);
|
||
assert.equal(ir, null,
|
||
'must return null when runner is unavailable so caller can warn-and-skip');
|
||
});
|
||
});
|
||
|
||
// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ─────────────────────
|
||
// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text.
|
||
// These assertions cover the three bug-critical render semantics that
|
||
// text-matching tests would miss (silent EOL/quoting/passthrough regressions).
|
||
|
||
describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => {
|
||
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
||
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
||
|
||
test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field');
|
||
assert.strictEqual(
|
||
ir.eol.cmd,
|
||
'\r\n',
|
||
'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions',
|
||
);
|
||
});
|
||
|
||
test('invocation.target has no shell-metachar leakage (clean absolute path)', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
const target = ir.invocation.target;
|
||
assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string');
|
||
// The target stored in the IR is the raw unquoted path — quoting happens at
|
||
// render time. A metachar in the raw value means the IR is already corrupted.
|
||
assert.ok(
|
||
!target.includes('"') && !target.includes("'") && !target.includes('`'),
|
||
`invocation.target must be the raw path without shell quoting, got: ${target}`,
|
||
);
|
||
assert.ok(
|
||
target.endsWith('.js'),
|
||
`invocation.target must resolve to the .js script, got: ${target}`,
|
||
);
|
||
});
|
||
|
||
test('passthroughArgs is true — shim forwards all args via %*', () => {
|
||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||
assert.strictEqual(
|
||
ir.passthroughArgs,
|
||
true,
|
||
'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ────
|
||
|
||
describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => {
|
||
test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => {
|
||
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
||
const cmd = projectManagedHookCommand({
|
||
absoluteRunner: runner,
|
||
scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js',
|
||
runtime: 'codex',
|
||
platform: 'darwin',
|
||
});
|
||
assert.ok(typeof cmd === 'string', 'must return a string on darwin');
|
||
assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim');
|
||
assert.ok(
|
||
cmd.includes('gsd-check-update.js'),
|
||
`darwin command must reference the .js hook directly, got: ${cmd}`,
|
||
);
|
||
});
|
||
|
||
test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => {
|
||
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
||
const cmd = projectManagedHookCommand({
|
||
absoluteRunner: runner,
|
||
scriptPath: '/home/me/.codex/hooks/gsd-check-update.js',
|
||
runtime: 'codex',
|
||
platform: 'linux',
|
||
});
|
||
assert.ok(typeof cmd === 'string', 'must return a string on linux');
|
||
assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim');
|
||
assert.ok(
|
||
cmd.includes('gsd-check-update.js'),
|
||
`linux command must reference the .js hook directly, got: ${cmd}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ──
|
||
|
||
describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('gsd-3426-');
|
||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||
// Stub the hook file that must exist for the hook to be registered
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => {
|
||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||
|
||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||
absoluteRunner: fakeRunner,
|
||
platform: 'win32',
|
||
});
|
||
|
||
assert.ok(result.wrote || result.changed, 'must write hooks.json on win32');
|
||
|
||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install');
|
||
|
||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||
.map((h) => h && h.command)
|
||
.filter((c) => typeof c === 'string');
|
||
|
||
assert.ok(commands.length > 0, 'must have at least one SessionStart hook command');
|
||
|
||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||
assert.ok(cmd, 'must have a gsd-check-update hook command');
|
||
|
||
// KEY ASSERTION: on win32, the command must reference a .cmd file — not bash
|
||
assert.ok(
|
||
cmd.includes('.cmd'),
|
||
`win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`,
|
||
);
|
||
assert.ok(
|
||
!cmd.toLowerCase().includes('bash'),
|
||
`win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`,
|
||
);
|
||
});
|
||
|
||
test('win32: .cmd shim file is written to the hooks directory', () => {
|
||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||
|
||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||
absoluteRunner: fakeRunner,
|
||
platform: 'win32',
|
||
});
|
||
|
||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||
assert.ok(
|
||
fs.existsSync(cmdShimPath),
|
||
`win32: .cmd shim must be written at ${cmdShimPath}`,
|
||
);
|
||
// File must be non-empty — structure check only (IR-first discipline)
|
||
const size = fs.statSync(cmdShimPath).size;
|
||
assert.ok(size > 0, '.cmd shim must have non-zero content');
|
||
});
|
||
|
||
test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => {
|
||
const fakeRunner = '"/usr/local/bin/node"';
|
||
|
||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||
absoluteRunner: fakeRunner,
|
||
platform: 'darwin',
|
||
});
|
||
|
||
assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin');
|
||
|
||
const hooksJson = JSON.parse(
|
||
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
||
);
|
||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||
.map((h) => h && h.command)
|
||
.filter((c) => typeof c === 'string');
|
||
|
||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||
assert.ok(cmd, 'must have a gsd-check-update hook command on darwin');
|
||
|
||
// Counter-test: darwin must NOT use a .cmd shim
|
||
assert.ok(
|
||
!cmd.endsWith('.cmd'),
|
||
`darwin hook command must NOT reference a .cmd shim, got: ${cmd}`,
|
||
);
|
||
assert.ok(
|
||
cmd.includes('gsd-check-update.js'),
|
||
`darwin hook command must reference the .js file directly, got: ${cmd}`,
|
||
);
|
||
|
||
// .cmd shim must NOT be written on darwin
|
||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||
assert.ok(
|
||
!fs.existsSync(cmdShimPath),
|
||
'darwin must NOT write a .cmd shim',
|
||
);
|
||
});
|
||
|
||
test('non-Windows (linux): same as darwin — no .cmd shim', () => {
|
||
const fakeRunner = '"/usr/local/bin/node"';
|
||
|
||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||
absoluteRunner: fakeRunner,
|
||
platform: 'linux',
|
||
});
|
||
|
||
const hooksJson = JSON.parse(
|
||
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
||
);
|
||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||
.map((h) => h && h.command)
|
||
.filter((c) => typeof c === 'string');
|
||
|
||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||
assert.ok(cmd, 'linux must have a gsd-check-update hook command');
|
||
assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim');
|
||
|
||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||
assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim');
|
||
});
|
||
});
|
||
|
||
// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ─────────────────
|
||
|
||
describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => {
|
||
let tmpDir;
|
||
|
||
function withCodexHome(dir, fn) {
|
||
const prev = process.env.CODEX_HOME;
|
||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too so this in-process install
|
||
// never touches the developer/CI machine's real home directory — confined
|
||
// entirely to `dir`, which the caller cleans up.
|
||
const prevHome = process.env.HOME;
|
||
const prevUserProfile = process.env.USERPROFILE;
|
||
process.env.CODEX_HOME = dir;
|
||
process.env.HOME = dir;
|
||
process.env.USERPROFILE = dir;
|
||
try { return fn(); }
|
||
finally {
|
||
if (prev == null) delete process.env.CODEX_HOME;
|
||
else process.env.CODEX_HOME = prev;
|
||
if (prevHome == null) delete process.env.HOME;
|
||
else process.env.HOME = prevHome;
|
||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = prevUserProfile;
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('gsd-3426-uninstall-');
|
||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||
// Write the .js hook (required by install) and a pre-existing .cmd shim
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'),
|
||
'@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n',
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('uninstall removes gsd-check-update.cmd from hooks directory', () => {
|
||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||
assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall');
|
||
|
||
withCodexHome(tmpDir, () => uninstall(true, 'codex'));
|
||
|
||
assert.ok(
|
||
!fs.existsSync(cmdShimPath),
|
||
`gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─
|
||
|
||
describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('gsd-3426-upgrade-');
|
||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => {
|
||
const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||
// Pre-existing stale hooks.json with node-runner command (v1.42.3 shape)
|
||
const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`;
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, 'hooks.json'),
|
||
JSON.stringify({
|
||
SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }],
|
||
}, null, 2),
|
||
);
|
||
|
||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||
absoluteRunner: fakeRunner,
|
||
platform: 'win32',
|
||
});
|
||
|
||
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||
.map((h) => h && h.command)
|
||
.filter((c) => typeof c === 'string');
|
||
|
||
const gsdCmds = commands.filter((c) => c.includes('gsd-check-update'));
|
||
// Exactly one managed hook after migration — no duplicates
|
||
assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`);
|
||
|
||
// Must be the .cmd shim
|
||
assert.ok(
|
||
gsdCmds[0].includes('.cmd'),
|
||
`migrated command must reference .cmd shim, got: ${gsdCmds[0]}`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3427-3433-codex-install-shape.test.cjs — consolidation epic #1969 (B1 #1970)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3427-3433-codex-install-shape (consolidation epic #1969 B1 #1970)", () => {
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const crypto = require('node:crypto');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||
|
||
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
||
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||
|
||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||
// scripts/build-hooks.js copies pre-built hook files into hooks/dist and
|
||
// syntax-checks them with vm — it does not compile/bundle anything. See
|
||
// tests/helpers/timeouts.cjs for the class-norm justification.
|
||
const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||
|
||
function withCodexHome(codexHome, fn) {
|
||
const prev = process.env.CODEX_HOME;
|
||
// #2088 (ADR-1239 upgrade 3): Codex skills now resolve an alternate install
|
||
// home rooted at the REAL os.homedir() ($HOME/.agents), independent of
|
||
// CODEX_HOME. Fake $HOME (and $USERPROFILE) too — using the sandbox root
|
||
// (codexHome's parent, since codexHome is conventionally `<tmpRoot>/.codex`
|
||
// in this file) — so this in-process install never touches the developer/CI
|
||
// machine's real home directory. tmpRoot is reclaimed by the caller's afterEach.
|
||
const prevHome = process.env.HOME;
|
||
const prevUserProfile = process.env.USERPROFILE;
|
||
const fakeHome = path.dirname(codexHome);
|
||
process.env.CODEX_HOME = codexHome;
|
||
process.env.HOME = fakeHome;
|
||
process.env.USERPROFILE = fakeHome;
|
||
try {
|
||
return fn();
|
||
} finally {
|
||
if (prev == null) delete process.env.CODEX_HOME;
|
||
else process.env.CODEX_HOME = prev;
|
||
if (prevHome == null) delete process.env.HOME;
|
||
else process.env.HOME = prevHome;
|
||
if (prevUserProfile == null) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = prevUserProfile;
|
||
}
|
||
}
|
||
|
||
function extractSessionStartCommandsFromHooksJson(value) {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
||
const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks))
|
||
? value.hooks
|
||
: value;
|
||
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
||
return sessionStart.flatMap((entry) => {
|
||
const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
|
||
return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string');
|
||
});
|
||
}
|
||
|
||
describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => {
|
||
let tmpRoot;
|
||
let codexHome;
|
||
|
||
beforeEach(() => {
|
||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||
throwIfFailed(
|
||
runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }),
|
||
`node ${BUILD_HOOKS_SCRIPT}`,
|
||
);
|
||
}
|
||
tmpRoot = createTempDir('gsd-3427-3433-');
|
||
codexHome = path.join(tmpRoot, '.codex');
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpRoot);
|
||
});
|
||
|
||
test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => {
|
||
// Stale legacy body — fresh install must overwrite this so Codex sees the
|
||
// current SKILL.md, not whatever was last on disk.
|
||
const legacySkillBody = '# old managed\n';
|
||
fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true });
|
||
fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody);
|
||
const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex');
|
||
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({
|
||
version: 1,
|
||
files: {
|
||
'skills/gsd-help/SKILL.md': legacyHash,
|
||
},
|
||
}, null, 2));
|
||
|
||
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
|
||
fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n');
|
||
|
||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||
|
||
// #2088: the managed gsd-* skill surface now regenerates at the
|
||
// canonical $HOME/.agents/skills root (fakeHome === tmpRoot here — see
|
||
// withCodexHome above), not under the legacy $CODEX_HOME/skills.
|
||
const newSkillsDir = codexSkillsRoot(tmpRoot);
|
||
const newEntries = fs.existsSync(newSkillsDir)
|
||
? fs.readdirSync(newSkillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
||
: [];
|
||
|
||
// #3562: $gsd-* commands are discoverable only when
|
||
// .agents/skills/gsd-*/SKILL.md exists. The installer must regenerate
|
||
// (not remove) the managed gsd-* directories.
|
||
assert.equal(newEntries.includes('gsd-help'), true);
|
||
const refreshedBody = fs.readFileSync(path.join(newSkillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
||
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
|
||
const frontmatter = parseFrontmatter(refreshedBody);
|
||
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
|
||
|
||
// #2088 migration: the installer cleans stale gsd-* dirs out of the old
|
||
// $CODEX_HOME/skills location on a pre-move install.
|
||
const legacyHelpDir = path.join(codexHome, 'skills', 'gsd-help');
|
||
assert.equal(fs.existsSync(legacyHelpDir), false, 'migration must remove the stale legacy gsd-help skill dir from $CODEX_HOME/skills');
|
||
|
||
// Unrelated user skills are preserved in place — migration only removes
|
||
// `gsd-*` dirs from the old location; non-gsd-* user dirs are untouched.
|
||
const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md');
|
||
assert.equal(fs.existsSync(userSkill), true, 'unrelated user skill must survive the #2088 migration');
|
||
});
|
||
|
||
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
|
||
const configToml = [
|
||
'[features]',
|
||
'codex_hooks = true',
|
||
'',
|
||
'[[hooks.SessionStart]]',
|
||
'[[hooks.SessionStart.hooks]]',
|
||
'type = "command"',
|
||
'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml);
|
||
|
||
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
||
SessionStart: [
|
||
{
|
||
hooks: [
|
||
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
||
],
|
||
},
|
||
],
|
||
}, null, 2));
|
||
|
||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||
|
||
const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
||
const tomlSessionStart = parsedToml.hooks?.SessionStart ?? [];
|
||
const tomlCommands = tomlSessionStart.flatMap((entry) =>
|
||
(Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string')
|
||
);
|
||
assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false);
|
||
|
||
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
||
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
||
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
|
||
assert.equal(gsdCommands.length, 1);
|
||
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
||
});
|
||
|
||
test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => {
|
||
const hooksDir = path.join(codexHome, 'hooks');
|
||
fs.mkdirSync(hooksDir, { recursive: true });
|
||
fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n');
|
||
const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||
|
||
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
||
SessionStart: [
|
||
{
|
||
hooks: [
|
||
{ type: 'command', command: `node "${managedHookPath}"` },
|
||
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
||
],
|
||
},
|
||
],
|
||
}, null, 2));
|
||
|
||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||
|
||
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
||
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
||
// On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command.
|
||
// Either way the managed hook is gone after uninstall — only the user hook remains.
|
||
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||
|
||
assert.equal(gsdCommands.length, 0);
|
||
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// 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 },
|
||
);
|
||
});
|
||
});
|