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:
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user