* fix(#1326): stop emitting Codex agents/openai.yaml sidecars; clean up stale ones Codex installs wrote an agents/openai.yaml sidecar under every managed gsd-* skill dir. Recent Codex builds index both SKILL.md and the sidecar, so each GSD skill appeared twice in autocomplete (canonical gsd-* name + humanized display_name). - Replace writeCodexSkillMetadataFiles / generateCodexSkillMetadataYaml with cleanupCodexSkillMetadataSidecars: Codex-only (if isCodex), removes stale managed gsd-*/agents/openai.yaml and prunes the now-empty agents/ dir. - Preserve user-owned dirs (gsd-dev-preferences), non-empty agents/ dirs, and non-gsd dirs; lstat-guard against symlinked agents/ so a delete can never escape the skills tree; fail-open per directory. - Codex relies on SKILL.md alone for /skills discovery. - Update USER-GUIDE/FEATURES docs and rewrite the #774 emission tests into cleanup tests. Scope: the sidecar duplicate only. The separate multi-root (~/.agents/skills shared-skills) duplicate facet is a distinct concern, not addressed here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1326): add changeset for Codex sidecar cleanup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2732 lines
124 KiB
JavaScript
2732 lines
124 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// 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.
|
|
|
|
/**
|
|
* 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 { execFileSync } = require('child_process');
|
|
const { cleanup } = require('./helpers.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');
|
|
before(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
});
|
|
}
|
|
});
|
|
|
|
const {
|
|
getCodexSkillAdapterHeader,
|
|
convertClaudeAgentToCodexAgent,
|
|
convertClaudeCommandToCodexSkill,
|
|
generateCodexAgentToml,
|
|
cleanupCodexSkillMetadataSidecars,
|
|
generateCodexConfigBlock,
|
|
stripGsdFromCodexConfig,
|
|
migrateCodexHooksMapFormat,
|
|
mergeCodexConfig,
|
|
install,
|
|
GSD_CODEX_MARKER,
|
|
CODEX_AGENT_SANDBOX,
|
|
parseTomlToObject,
|
|
resolveNodeRunner,
|
|
} = require('../bin/install.js');
|
|
|
|
const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
|
|
function runCodexInstall(codexHome, cwd = path.join(__dirname, '..')) {
|
|
const previousCodeHome = process.env.CODEX_HOME;
|
|
const previousCwd = process.cwd();
|
|
process.env.CODEX_HOME = codexHome;
|
|
|
|
try {
|
|
process.chdir(cwd);
|
|
return install(true, 'codex');
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
if (previousCodeHome === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = previousCodeHome;
|
|
}
|
|
}
|
|
}
|
|
|
|
function readCodexConfig(codexHome) {
|
|
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
}
|
|
|
|
function writeCodexConfig(codexHome, content) {
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
|
}
|
|
|
|
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.4"'), '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\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 = [
|
|
/(^|\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');
|
|
assert.match(
|
|
result,
|
|
/Resolved `reasoning_effort="low\|medium\|high\|xhigh"` \(`xhigh` is a GSD\/Codex tier, not a generic runtime enum\) → pass `reasoning_effort`\s+to `spawn_agent` when the runtime\/tool supports it/,
|
|
'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');
|
|
assert.ok(result.includes('wait(ids)'), 'documents parallel wait pattern');
|
|
assert.ok(result.includes('close_agent'), 'documents close_agent cleanup');
|
|
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');
|
|
});
|
|
|
|
test('defaults unknown agents to read-only', () => {
|
|
const result = generateCodexAgentToml('gsd-unknown', sampleAgent);
|
|
assert.ok(result.includes('sandbox_mode = "read-only"'), 'defaults to read-only');
|
|
});
|
|
|
|
// ─── #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('emits reasoning effort when runtime resolver pins Codex model (#838)', () => {
|
|
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 must pin model');
|
|
assert.ok(
|
|
result.includes('model_reasoning_effort ='),
|
|
'reasoning effort is safe to emit when runtime resolver pins model'
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// ─── #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"');
|
|
});
|
|
});
|
|
|
|
// ─── 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 16 runtimes.
|
|
|
|
// ─── CODEX_AGENT_SANDBOX mapping ────────────────────────────────────────────────
|
|
|
|
describe('CODEX_AGENT_SANDBOX', () => {
|
|
test('has all 11 agents mapped', () => {
|
|
const agentNames = Object.keys(CODEX_AGENT_SANDBOX);
|
|
assert.strictEqual(agentNames.length, 11, 'has 11 agents');
|
|
});
|
|
|
|
test('workspace-write agents have write tools', () => {
|
|
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(CODEX_AGENT_SANDBOX[name], 'workspace-write', `${name} is workspace-write`);
|
|
}
|
|
});
|
|
|
|
test('read-only agents have no write tools', () => {
|
|
const readOnlyAgents = ['gsd-plan-checker', 'gsd-integration-checker'];
|
|
for (const name of readOnlyAgents) {
|
|
assert.strictEqual(CODEX_AGENT_SANDBOX[name], 'read-only', `${name} is 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('does not include feature flags or agents table header', () => {
|
|
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');
|
|
// Should not have bare [agents] table header (only [agents.<name>] structs).
|
|
assert.ok(!result.match(/^\[agents\]\s*$/m), 'no bare [agents] table');
|
|
// Should not emit [[agents]] sequence format (rejected by Codex 0.124.0).
|
|
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format');
|
|
assert.ok(!result.includes('max_threads'), 'no max_threads');
|
|
assert.ok(!result.includes('max_depth'), 'no max_depth');
|
|
});
|
|
|
|
test('#2727: emits [agents.<name>] struct format (Codex 0.120.0+, replaces #2645 [[agents]])', () => {
|
|
const result = generateCodexConfigBlock(agents);
|
|
// One [agents.<name>] header per agent — no [[agents]] sequence.
|
|
assert.ok(result.includes('[agents.gsd-executor]'), 'executor has struct header');
|
|
assert.ok(result.includes('[agents.gsd-planner]'), 'planner has struct header');
|
|
// Struct format uses the key as the name; no name = field.
|
|
assert.ok(!result.includes('name = "gsd-executor"'), 'no name field in struct format');
|
|
assert.ok(!result.includes('name = "gsd-planner"'), 'no name field in struct format');
|
|
assert.ok(!result.includes('[[agents]]'), 'no sequence format headers');
|
|
});
|
|
|
|
test('#2727: block is a valid TOML struct shape (no [[agents]] sequence headers)', () => {
|
|
const result = generateCodexConfigBlock(agents);
|
|
// Must not contain [[agents]] array-of-tables syntax (rejected by Codex 0.124.0).
|
|
assert.ok(!result.includes('[[agents]]'), 'no [[agents]] sequence format present');
|
|
// Must contain [agents.<name>] struct headers.
|
|
const structHeaders = (result.match(/^\[agents\.[^\]]+\]\s*$/gm) || []).length;
|
|
assert.strictEqual(structHeaders, 2, 'one [agents.<name>] struct header per agent');
|
|
});
|
|
|
|
test('includes per-agent sections with relative paths (no targetDir)', () => {
|
|
const result = generateCodexConfigBlock(agents);
|
|
assert.ok(result.includes('[agents.gsd-executor]'), 'has executor entry');
|
|
assert.ok(result.includes('[agents.gsd-planner]'), 'has planner entry');
|
|
assert.ok(result.includes('config_file = "agents/gsd-executor.toml"'), 'relative config_file without targetDir');
|
|
assert.ok(result.includes('"Executes plans"'), 'has executor description');
|
|
});
|
|
|
|
test('uses absolute config_file paths when targetDir is provided', () => {
|
|
const result = generateCodexConfigBlock(agents, '/home/user/.codex');
|
|
assert.ok(result.includes('config_file = "/home/user/.codex/agents/gsd-executor.toml"'), 'absolute executor path');
|
|
assert.ok(result.includes('config_file = "/home/user/.codex/agents/gsd-planner.toml"'), 'absolute planner path');
|
|
assert.ok(!result.includes('config_file = "agents/'), 'no relative paths when targetDir given');
|
|
});
|
|
|
|
test('#2727: emits [agents.<name>] struct format by default (Codex 0.124.0+)', () => {
|
|
const result = generateCodexConfigBlock(agents);
|
|
// Codex 0.124.0 expects [agents.<name>] struct format, not [[agents]] sequence format.
|
|
// [[agents]] was introduced in #2645 but is rejected by codex-cli 0.124.0 with
|
|
// "invalid type: sequence, expected struct AgentsToml".
|
|
assert.ok(!result.includes('[[agents]]'), 'should not emit [[agents]] sequence format');
|
|
assert.ok(result.includes('[agents.'), 'should emit [agents.<name>] struct format');
|
|
assert.ok(result.includes('[agents.gsd-executor]'), 'executor uses struct header');
|
|
assert.ok(result.includes('[agents.gsd-planner]'), 'planner uses struct header');
|
|
// Struct format must NOT have a name = field (name is the key, not a value)
|
|
assert.ok(!result.includes('name = "gsd-executor"'), 'no name field in struct format');
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent in struct format');
|
|
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');
|
|
assert.ok(content.includes('Updated description'), 'has new description');
|
|
assert.ok(content.includes('[agents.gsd-planner]'), 'has new agent in struct format');
|
|
// Verify no duplicate markers
|
|
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), '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]'), 'has agent in struct format');
|
|
});
|
|
|
|
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]'), 'has agent in struct format');
|
|
});
|
|
|
|
test('case 3 strips existing [agents.gsd-*] sections before appending fresh block', () => {
|
|
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');
|
|
// After merge, GSD block is after the marker. Count [agents.gsd-executor] headers:
|
|
// exactly one should exist (the one in the freshly-written GSD block).
|
|
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
|
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
// Struct format does not use name = field
|
|
assert.ok(!content.match(/^name = "gsd-executor"/m), 'no name = field in struct format');
|
|
|
|
assert.ok(content.includes('[model]'), 'preserves user content');
|
|
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section');
|
|
assert.strictEqual(gsdStructCount, 1, 'keeps exactly one [agents.gsd-executor] struct entry');
|
|
assert.strictEqual(markerCount, 1, 'adds exactly one marker block');
|
|
assert.ok(!/\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]'), 'has agent in struct format');
|
|
// Verify no duplicate markers
|
|
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
|
});
|
|
|
|
test('case 2 does not inject feature keys', () => {
|
|
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');
|
|
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent from fresh block in struct format');
|
|
});
|
|
|
|
test('case 2 strips leaked [agents] and [agents.gsd-*] from before content', () => {
|
|
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');
|
|
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent from fresh block in struct format');
|
|
// 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', () => {
|
|
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');
|
|
// New struct format: exactly one [agents.gsd-executor] header in the GSD block (after marker)
|
|
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'exactly one struct agent header in GSD block');
|
|
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', () => {
|
|
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',
|
|
);
|
|
// New struct format: exactly one [agents.gsd-executor] in the GSD block (after marker)
|
|
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'exactly one struct agent header in GSD block');
|
|
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');
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
assert.ok(config.includes('[agents.gsd-executor]'), 'has executor agent in struct format');
|
|
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
|
|
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);
|
|
|
|
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}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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\]\n([\s\S]*?)(?=\n\[|$)/);
|
|
assert.ok(featuresMatch, 'features section found');
|
|
|
|
const featuresBody = featuresMatch[1];
|
|
const nonBooleanKeys = featuresBody.split('\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\]\n([\s\S]*?)(?=\n\[|$)/);
|
|
const featuresBody = featuresMatch[1];
|
|
const nonBooleanKeys = featuresBody.split('\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('config_file paths are absolute using CODEX_HOME', () => {
|
|
runCodexInstall(codexHome);
|
|
|
|
const content = readCodexConfig(codexHome);
|
|
const agentsDir = path.join(codexHome, 'agents').replace(/\\/g, '/');
|
|
// All config_file values should use absolute paths
|
|
const configFileLines = content.split('\n').filter(l => l.startsWith('config_file = '));
|
|
assert.ok(configFileLines.length > 0, 'has config_file entries');
|
|
for (const line of configFileLines) {
|
|
assert.ok(line.includes(agentsDir), `absolute path in: ${line}`);
|
|
}
|
|
assert.ok(!content.includes('config_file = "agents/'), 'no relative config_file paths');
|
|
});
|
|
|
|
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\]\n([\s\S]*?)(?=\n\[|$)/);
|
|
assert.ok(featuresMatch, 'features section found');
|
|
const featuresBody = featuresMatch[1];
|
|
const nonBooleanKeys = featuresBody.split('\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\]\n([\s\S]*?)(?=\n\[|$)/);
|
|
assert.ok(featuresMatch, 'features section found');
|
|
const featuresBody = featuresMatch[1];
|
|
const nonBooleanKeys = featuresBody.split('\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');
|
|
assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents.'), 'keeps codex_hooks before the first managed [agents.<name>] struct entry');
|
|
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');
|
|
assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block in struct format');
|
|
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');
|
|
assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block in struct format');
|
|
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);
|
|
});
|
|
});
|
|
|
|
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('\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\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 = path.join(codexHome, 'skills');
|
|
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)`);
|
|
}
|
|
});
|
|
});
|