fix(#725): route Codex gsd-tools calls through shim (#731)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jeremy McSpadden
2026-06-10 13:44:43 -05:00
committed by GitHub
parent 5e8a723089
commit fb37fa7dd5
85 changed files with 315 additions and 80 deletions

View File

@@ -57,6 +57,22 @@ function stripAnsi(s) {
return s.replace(/\x1b\[[0-9;]*m/g, '');
}
function assertNoBareGsdToolsInvocation(content, label) {
const patterns = [
/(^|\n)[ \t]*gsd-tools\s/,
/\$\(\s*gsd-tools\s/,
/`\s*gsd-tools\s/,
/(?:&&|\|\||[;|])\s*gsd-tools\s/,
];
for (const pattern of patterns) {
assert.doesNotMatch(
content,
pattern,
`${label} must not contain a command-position bare gsd-tools invocation`,
);
}
}
/**
* Walk commands/gsd/**\/*.md and return the set of skill names the installer
* is contractually obligated to produce. Naming rule mirrors
@@ -253,6 +269,21 @@ describe('bug-3582: Codex global install materializes the skill surface', { conc
}
});
test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => {
const skillsDir = path.join(installRun.codexHome, 'skills');
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.map(e => e.name);
for (const name of skillDirs) {
const content = fs.readFileSync(
path.join(skillsDir, name, 'SKILL.md'),
'utf-8',
);
assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`);
}
});
test('installer success log mentions skills/ — never claims success while skipping', () => {
// The 1.42.2 user-visible failure mode was a successful install that
// printed "Skipped Codex skill-copy generation (Codex discovers

View File

@@ -122,6 +122,22 @@ function assertUsesOnlyEol(content, eol) {
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', () => {
@@ -240,6 +256,35 @@ node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" commit "docs: resolve"`;
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 ────────────────────────────────────────────
@@ -290,6 +335,37 @@ node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init`;
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
@@ -1485,6 +1561,21 @@ describe('installCodexConfig (integration)', () => {
}
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) ─────────────────────────────

View File

@@ -17,6 +17,8 @@
* (F) Regression locks: the snippet file contains no /gsd-tools substring; and
* no line in workflows/do.md matches /\/gsd[:-][a-z]/ (dispatcher-parity
* scanner must not read the preamble as a slash-command stub).
* (H) Codex shim fallback: when PATH has no gsd-tools, $HOME/.codex/gsd-core/bin
* can satisfy gsd_run for Codex shim-only installs.
*/
// allow-test-rule: structural parity/drift guard — asserts literal presence/absence of the canonical gsd_run launcher and the retired $GSD_SDK / `/gsd-tools` tokens across workflow markdown; there is no typed IR for "this source file does not contain substring X".
@@ -395,6 +397,97 @@ describe('runtime-launcher-parity (#373)', () => {
);
});
// ─── (H) Codex shim fallback behavioral ------------------------------------
test('(H) gsd_run resolves $HOME/.codex/gsd-core/bin/ shim when PATH has no gsd-tools', () => {
const CODEX_HOME_PROBE = '.codex/gsd-core/bin/';
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
assert.ok(
snippetContent.includes(CODEX_HOME_PROBE),
`_runtime-launcher.snippet.sh must contain the Codex fallback arm (probing "${CODEX_HOME_PROBE}").`,
);
const missing = [];
for (const f of collectWorkflowFiles()) {
const content = fs.readFileSync(f, 'utf8');
const blocks = extractShellBlocks(content);
const allBlockLines = blocks.flatMap((b) => b.lines);
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
if (!fileHasGsdRun) continue;
if (!allBlockLines.join('\n').includes(CODEX_HOME_PROBE)) {
missing.push(path.relative(WORKFLOWS_DIR, f));
}
}
assert.deepStrictEqual(
missing,
[],
`These workflow files use gsd_run but are missing the Codex fallback arm ("${CODEX_HOME_PROBE}"). ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate:\n` +
missing.join('\n'),
);
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-home-'));
const fakeRuntime = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-rt-'));
try {
const codexBinDir = path.join(fakeHome, '.codex', 'gsd-core', 'bin');
fs.mkdirSync(codexBinDir, { recursive: true });
const stubPath = path.join(codexBinDir, 'gsd-tools.cjs');
fs.writeFileSync(
stubPath,
'#!/usr/bin/env node\nconsole.log("CODEX_HOME_STUB:" + process.argv.slice(2).join(","));\n',
);
fs.chmodSync(stubPath, 0o755);
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(fakeRuntime)}\n` +
`export HOME=${JSON.stringify(fakeHome)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run query init.quick\n`;
const scriptPath = path.join(fakeRuntime, 'test-codex-home-fb.sh');
fs.writeFileSync(scriptPath, scriptContent);
const hasExecutable = (dir, name) => {
try {
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
return true;
} catch {
return false;
}
};
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => !hasExecutable(p, 'gsd-tools'));
if (!systemPaths.some((p) => hasExecutable(p, 'node'))) {
const nodeShimDir = path.join(fakeRuntime, 'node-shim');
fs.mkdirSync(nodeShimDir, { recursive: true });
fs.symlinkSync(process.execPath, path.join(nodeShimDir, 'node'));
systemPaths.unshift(nodeShimDir);
}
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: systemPaths.join(path.delimiter), HOME: fakeHome },
});
const normStdout = stdout.replace(/\\/g, '/');
assert.ok(
normStdout.includes('.codex/gsd-core/bin/'),
`Expected GSD_TOOLS to resolve into .codex/gsd-core/bin/, got:\n${stdout.trim()}`,
);
assert.ok(
stdout.includes('CODEX_HOME_STUB:query,init.quick'),
`Expected Codex shim stub output, got:\n${stdout.trim()}`,
);
} finally {
cleanup(fakeHome);
cleanup(fakeRuntime);
}
});
// ─── (F) Regression locks: no /gsd-tools substring; no do.md dispatcher false-positive ──
test('(F) snippet has no /gsd-tools substring; do.md has no /gsd[:-][a-z] matches', () => {
// (F1) The snippet must not contain the literal substring /gsd-tools.

View File

@@ -77,7 +77,8 @@ describe('skill frontmatter: /gsd-plan-phase --research-phase flag absorbs the s
// The arg-parsing section of the workflow must mention the new flag
// by name. This is the structural seam the LLM follows.
// Anchored to the argument/flags section to avoid false positives from prose.
const argsIdx = content.search(/(?:argument|args?|flags?)\b/i);
const argsHeader = '## 2. Parse and Normalize Arguments';
const argsIdx = content.indexOf(argsHeader);
assert.ok(argsIdx >= 0, 'plan-phase workflow must contain an argument/flags section');
const argsWindow = content.slice(argsIdx, argsIdx + 1200);
assert.ok(