Files
msd-core/tests/runtime-launcher-parity.test.cjs

726 lines
30 KiB
JavaScript

'use strict';
/**
* Parity test for bug #373: space-safe gsd_run launcher
*
* Asserts:
* (A) No retired GSD_SDK token remains in any workflow .md file.
* (B) Each workflow .md that uses gsd_run contains EXACTLY ONE canonical preamble
* (byte-equal to _runtime-launcher.snippet.sh), and it appears before the first
* gsd_run call. NOT every bash block — exactly one per file (define once, use
* across blocks — original footprint).
* (C) Space-safe behavioral: a RUNTIME_DIR path with spaces in it resolves
* and calls gsd-tools.cjs correctly (no word-split, no {}).
* (D) Loud guard behavioral: missing gsd-tools.cjs exits non-zero and emits
* "not found" to stderr.
* (E) PATH fallback behavioral: when no local gsd-tools.cjs, the elif branch
* resolves to the gsd-tools binary on PATH (#3668).
* (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".
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
/**
* Read the canonical preamble from the snippet file (all lines, no trailing newline).
*/
function expectedPreamble() {
const raw = fs.readFileSync(SNIPPET_FILE, 'utf8');
const lines = raw.split(/\r?\n/);
// Strip trailing empty element produced by a trailing newline.
const content = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
assert.ok(content.length >= 1, `_runtime-launcher.snippet.sh must not be empty`);
return content; // array of strings
}
/**
* Extract all bash/sh/shell fenced blocks from markdown content.
* Returns array of { index, lines } where index is 0-based block count,
* and lines is the array of content lines (without the fence markers).
*
* Handles both column-0 fences (```bash) and indented fences ( ```bash).
*/
function extractShellBlocks(content) {
const allLines = content.split(/\r?\n/);
const blocks = [];
let inBlock = false;
let blockLang = null;
let blockLines = [];
let blockIndex = 0;
let blockIndent = '';
let closingPattern = null;
for (let i = 0; i < allLines.length; i++) {
const line = allLines[i];
if (!inBlock) {
const fenceOpen = line.match(/^(\s*)```(\w+)?\s*$/);
if (fenceOpen) {
inBlock = true;
blockIndent = fenceOpen[1];
blockLang = (fenceOpen[2] || '').toLowerCase();
blockLines = [];
// Closing pattern: same indent prefix + ```
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
continue;
}
} else {
if (closingPattern.test(line)) {
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
blocks.push({ index: blockIndex, lang: blockLang, lines: blockLines });
blockIndex++;
}
inBlock = false;
blockLang = null;
blockLines = [];
blockIndent = '';
closingPattern = null;
continue;
}
blockLines.push(line);
}
}
return blocks;
}
/**
* Collect all workflow .md files recursively under WORKFLOWS_DIR.
* Excludes _runtime-launcher.snippet.sh (not a markdown file).
*/
function collectWorkflowFiles() {
const results = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(full);
}
}
}
walk(WORKFLOWS_DIR);
return results;
}
/**
* Collect all agent .md files under AGENTS_DIR (non-recursive — agents/ has no subdirs,
* but collectFiles in the sync script is recursive-safe; we mirror that here).
*/
function collectAgentFiles() {
const results = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(full);
}
}
}
walk(AGENTS_DIR);
return results;
}
describe('runtime-launcher-parity (#373)', () => {
// ─── (A) No retired GSD_SDK token ────────────────────────────────────────
test('(A) no GSD_SDK token in any workflow .md file', () => {
const files = collectWorkflowFiles();
assert.ok(files.length > 0, 'expected at least one workflow .md file');
const offending = [];
for (const f of files) {
const content = fs.readFileSync(f, 'utf8');
if (content.includes('GSD_SDK')) {
offending.push(path.relative(WORKFLOWS_DIR, f));
}
}
assert.deepStrictEqual(
offending,
[],
'Found GSD_SDK (retired token) in workflow files — run `node scripts/sync-runtime-launcher.cjs` to fix:\n' +
offending.join('\n'),
);
});
// ─── (B) Exactly ONE canonical preamble per using file ───────────────────
test('(B) each workflow .md using gsd_run contains exactly ONE canonical preamble, before the first gsd_run call', () => {
const preamble = expectedPreamble();
const preambleStr = preamble.join('\n');
const files = collectWorkflowFiles();
assert.ok(files.length > 0, 'expected at least one workflow .md file');
const violations = [];
for (const f of files) {
const rel = path.relative(WORKFLOWS_DIR, f);
const content = fs.readFileSync(f, 'utf8');
const blocks = extractShellBlocks(content);
// Collect all block lines in document order for flat analysis
const allBlockLines = [];
for (const blk of blocks) {
allBlockLines.push(...blk.lines);
}
// Does this file use gsd_run at all?
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
if (!fileHasGsdRun) continue;
// Count preamble occurrences across all shell content of this file
// Flatten all block lines with a separator so multi-block boundary doesn't create false match
const allContent = allBlockLines.join('\n');
let preambleCount = 0;
let searchPos = 0;
while (true) {
const idx = allContent.indexOf(preambleStr, searchPos);
if (idx === -1) break;
preambleCount++;
searchPos = idx + preambleStr.length;
}
if (preambleCount !== 1) {
violations.push(
`${rel}: expected exactly 1 canonical preamble occurrence in bash blocks, found ${preambleCount}. ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to fix.`,
);
continue;
}
// Verify preamble appears BEFORE the first gsd_run call (in document order)
// Find the line index of the preamble start vs the first gsd_run call in the flat content
const preamblePos = allContent.indexOf(preambleStr);
const firstGsdRunPos = allContent.search(/\bgsd_run\b/);
// The first gsd_run WITHIN the preamble itself (the function definition) is fine.
// We need to verify that no gsd_run CALL (i.e. gsd_run used as a command, not in a
// function definition body) appears before the preamble starts.
// Simple check: preamble starts at or before the first gsd_run occurrence
if (preamblePos > firstGsdRunPos) {
violations.push(
`${rel}: preamble appears AFTER the first gsd_run reference — it must precede all gsd_run calls.`,
);
}
}
assert.deepStrictEqual(
violations,
[],
'Files with gsd_run calls have wrong preamble count or ordering:\n' +
violations.join('\n---\n'),
);
});
// ─── (C) Space-safe behavioral test ──────────────────────────────────────
test('(C) gsd_run works with a RUNTIME_DIR path containing spaces', () => {
// Create temp dir whose path contains a space
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 '));
try {
const binDir = path.join(base, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Stub gsd-tools.cjs that prints its argv
const stub = path.join(binDir, 'gsd-tools.cjs');
fs.writeFileSync(stub, '#!/usr/bin/env node\nconsole.log("STUB:" + process.argv.slice(2).join(","));\n');
fs.chmodSync(stub, 0o755);
// Build a shell script: set RUNTIME_DIR, source preamble, run gsd_run
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
snippet +
`\ngsd_run query state.json\n`;
const scriptPath = path.join(base, 'test-space.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], { encoding: 'utf8' });
assert.ok(
stdout.includes('STUB:query,state.json'),
`Expected stdout to contain "STUB:query,state.json" but got: ${stdout.trim()}`,
);
} finally {
cleanup(base);
}
});
// ─── (D) Loud guard: missing runtime is fatal ─────────────────────────────
test('(D) missing gsd-tools.cjs and no PATH gsd-tools causes loud non-zero exit with "not found" on stderr', () => {
// Create temp dir with a space in the name, but NO gsd-tools.cjs.
// We ensure gsd-tools is not on PATH by prepending a dir that has no
// gsd-tools binary (system binaries remain on PATH so bash/node work).
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 notools '));
// Place a no-op dir first in PATH; no gsd-tools stub there.
const noToolsBin = path.join(base, 'nobin');
fs.mkdirSync(noToolsBin, { recursive: true });
try {
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// The script must also unset any GSD_TOOLS env var that might leak in
const scriptContent =
`unset GSD_TOOLS\n` +
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
snippet +
`\ngsd_run query state.json\n`;
const scriptPath = path.join(base, 'test-guard.sh');
fs.writeFileSync(scriptPath, scriptContent);
// Build a PATH that has noToolsBin first (no gsd-tools stub there) but retains
// system paths needed for bash. Exclude any PATH entry that contains a gsd-tools binary.
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
.split(path.delimiter)
.filter((p) => {
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
catch { return true; }
});
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
let threw = false;
let stderrOutput = '';
try {
execFileSync('bash', [scriptPath], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PATH: isolatedPath, HOME: base },
});
} catch (err) {
threw = true;
stderrOutput = err.stderr || '';
}
assert.ok(threw, 'Expected the script to exit non-zero when gsd-tools.cjs is missing and gsd-tools is not on PATH');
assert.ok(
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
);
} finally {
cleanup(base);
}
});
// ─── (E) PATH fallback behavioral (#3668) ────────────────────────────────
test('(E) PATH fallback: uses installed gsd-tools when no local gsd-tools.cjs present', () => {
// Create a temp dir with NO local gsd-core/bin/gsd-tools.cjs.
// Place an executable gsd-tools stub on a dedicated PATH dir.
// RUNTIME_DIR points somewhere that has no gsd-tools.cjs.
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 pathfb '));
try {
const pathBinDir = path.join(base, 'bin');
fs.mkdirSync(pathBinDir, { recursive: true });
// Stub installed gsd-tools binary that prints a marker
const stubPath = path.join(pathBinDir, 'gsd-tools');
fs.writeFileSync(stubPath, '#!/bin/sh\necho "installed:$*"\n');
fs.chmodSync(stubPath, 0o755);
// RUNTIME_DIR points to base — no gsd-core/bin/gsd-tools.cjs there
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
const scriptContent =
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
snippet +
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
`gsd_run query state.json\n`;
const scriptPath = path.join(base, 'test-pathfb.sh');
fs.writeFileSync(scriptPath, scriptContent);
const stdout = execFileSync('bash', [scriptPath], {
encoding: 'utf8',
env: { ...process.env, PATH: `${pathBinDir}${path.delimiter}${process.env.PATH || ''}` },
});
// The PATH fallback must have resolved GSD_TOOLS to the stub binary.
// Normalize backslashes → forward slashes so the assertion works on Windows
// (git-bash emits POSIX paths while Node's os.tmpdir() returns the Windows form).
// Assert by suffix (/bin/gsd-tools, no .cjs extension) rather than absolute prefix
// because the prefix differs between Windows and POSIX.
// Use .+ (not \S*) to tolerate paths that contain spaces.
const normStdout = stdout.replace(/\\/g, '/');
assert.match(
normStdout,
/GSD_TOOLS=.+\/bin\/gsd-tools(?:\s|$)/m,
`Expected GSD_TOOLS to resolve to the installed PATH stub (suffix /bin/gsd-tools), got: ${stdout.trim()}`,
);
assert.doesNotMatch(
normStdout,
/GSD_TOOLS=.+\.cjs/m,
`Expected GSD_TOOLS NOT to point to a .cjs file in PATH fallback, got: ${stdout.trim()}`,
);
// The stub must have been invoked with the query arguments
assert.ok(
stdout.includes('installed:query state.json'),
`Expected stdout to contain "installed:query state.json" (PATH stub output), got: ${stdout.trim()}`,
);
} finally {
cleanup(base);
}
});
// ─── (G) ~/.claude fallback arm is present (#211) ───────────────────────────
test('(G) snippet and all propagated workflow .md files contain the $HOME/.claude fallback arm between PATH check and hard error', () => {
// The resolution order must be:
// (1) local/RUNTIME_DIR → (2) PATH → (3) $HOME/.claude/gsd-core/bin → (4) hard error
// We probe for .claude/gsd-core/bin (using ${_GSD_SHIM_NAME} indirection)
// between the `command -v gsd-tools` elif and the hard-error else branch.
const CLAUDE_HOME_PROBE = '.claude/gsd-core/bin/';
// Assert snippet itself contains the probe
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
assert.ok(
snippetContent.includes(CLAUDE_HOME_PROBE),
`_runtime-launcher.snippet.sh must contain the $HOME/.claude fallback arm (probing "${CLAUDE_HOME_PROBE}"). ` +
`Add an elif arm that checks $HOME/.claude/gsd-core/bin/\${_GSD_SHIM_NAME} before the hard-error else.`,
);
// Assert the probe appears BEFORE the hard-error text in the snippet
const probePos = snippetContent.indexOf(CLAUDE_HOME_PROBE);
const errorPos = snippetContent.indexOf('exit 1');
assert.ok(
probePos < errorPos,
`The $HOME/.claude fallback arm (at index ${probePos}) must appear before "exit 1" (at index ${errorPos}) in the snippet.`,
);
// Assert every propagated workflow .md file that uses gsd_run also contains the probe
const files = collectWorkflowFiles();
const missing = [];
for (const f of files) {
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;
const allContent = allBlockLines.join('\n');
if (!allContent.includes(CLAUDE_HOME_PROBE)) {
missing.push(path.relative(WORKFLOWS_DIR, f));
}
}
assert.deepStrictEqual(
missing,
[],
`These workflow files use gsd_run but are missing the $HOME/.claude fallback arm ("${CLAUDE_HOME_PROBE}"). ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to propagate:\n` +
missing.join('\n'),
);
});
// ─── (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.
// The _GSD_SHIM_NAME indirection ensures bin/${_GSD_SHIM_NAME} instead of
// bin/gsd-tools.cjs — so the do.md dispatcher regex /\/gsd[:-]([a-z]...)/ never
// misreads a preamble line as a slash-command stub.
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
assert.ok(
!snippetContent.includes('/gsd-tools'),
`_runtime-launcher.snippet.sh must not contain the literal "/gsd-tools" substring. ` +
`Use bin/\${_GSD_SHIM_NAME} indirection to keep the /gsd[:-] scanner from ` +
`misreading it as a slash-command stub. Found in snippet:\n` +
snippetContent.split(/\r?\n/).filter((l) => l.includes('/gsd-tools')).join('\n'),
);
// (F2) workflows/do.md must not contain the literal substring /gsd-tools
// (the specific path that leaks when _GSD_SHIM_NAME indirection is bypassed).
// The bug-2954 dispatcher scanner /\/gsd[:-]([a-z]...)/ would misread
// /gsd-tools as a slash-command stub named "tools" — which is not shipped.
// Note: /gsd:command references (with colon) in the dispatch table are
// legitimate and are NOT checked here.
const doMdPath = path.join(WORKFLOWS_DIR, 'do.md');
const doMdContent = fs.readFileSync(doMdPath, 'utf8');
const offendingLines = doMdContent
.split(/\r?\n/)
.filter((l) => /\/gsd-tools/.test(l));
assert.deepStrictEqual(
offendingLines,
[],
`workflows/do.md contains the literal "/gsd-tools" substring which the dispatcher-parity ` +
`scanner (bug-2954) misreads as a slash-command stub. Use \${_GSD_SHIM_NAME} indirection. ` +
`Offending lines:\n` +
offendingLines.join('\n'),
);
});
});
// ─── Issue #381: standalone gsd_run executable + CLAUDE_ENV_FILE persistence ──
describe('runtime-launcher-parity — standalone executable (#381)', () => {
const BIN_DIR = path.join(__dirname, '..', 'gsd-core', 'bin');
const GSD_RUN_SRC = path.join(BIN_DIR, 'gsd_run');
// ─── (I) gsd_run executable delegates to gsd-tools.cjs beside it ──────────
test('(I) gsd_run executable delegates to gsd-tools.cjs beside it', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-381-I-'));
try {
const binDir = path.join(base, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Copy the real gsd_run executable into the temp bin dir
fs.copyFileSync(GSD_RUN_SRC, path.join(binDir, 'gsd_run'));
fs.chmodSync(path.join(binDir, 'gsd_run'), 0o755);
// Write a stub gsd-tools.cjs that echoes its args
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
`console.log('GSD_TOOLS_STUB:' + process.argv.slice(2).join(' '))`,
);
const stdout = execFileSync('sh', [path.join(binDir, 'gsd_run'), 'query', 'x'], {
encoding: 'utf8',
});
assert.ok(
stdout.includes('GSD_TOOLS_STUB:query x'),
`Expected stdout to contain "GSD_TOOLS_STUB:query x", got: ${stdout.trim()}`,
);
} finally {
cleanup(base);
}
});
// ─── (J) preamble persists bin dir to CLAUDE_ENV_FILE ─────────────────────
test('(J) preamble persists bin dir to CLAUDE_ENV_FILE so a fresh shell resolves gsd_run', () => {
// Use a RUNTIME_DIR whose path contains a SPACE to prove single-quote safety.
const baseParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-381-J-'));
const base = path.join(baseParent, 'has space');
try {
const binDir = path.join(base, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
// gsd_run stub that prints GSD_RUN_STUB:<args>
const gsdRunStub = path.join(binDir, 'gsd_run');
fs.writeFileSync(gsdRunStub, '#!/bin/sh\necho "GSD_RUN_STUB:$*"\n');
fs.chmodSync(gsdRunStub, 0o755);
// gsd-tools.cjs stub (must exist for preamble first arm to win)
fs.writeFileSync(path.join(binDir, 'gsd-tools.cjs'), '// stub');
const envFile = path.join(baseParent, 'envfile');
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Script: just source the preamble with RUNTIME_DIR + CLAUDE_ENV_FILE set
const preambleScript = path.join(baseParent, 'run-preamble.sh');
fs.writeFileSync(preambleScript, snippet);
execFileSync('bash', [preambleScript], {
encoding: 'utf8',
env: {
RUNTIME_DIR: base,
CLAUDE_ENV_FILE: envFile,
PATH: process.env.PATH,
},
});
// Assert envfile exists and the persisted line is single-quoted
assert.ok(fs.existsSync(envFile), `Expected CLAUDE_ENV_FILE (${envFile}) to be created after preamble runs`);
const envFileContent = fs.readFileSync(envFile, 'utf8');
// The persisted line must single-quote the directory (neutralising $, spaces, etc.)
// and keep "$PATH" expanding at source time.
// Expected form: export PATH='<dir>':"$PATH"
assert.ok(
envFileContent.includes("export PATH='"),
`Expected envfile to contain single-quoted export PATH line, got: ${envFileContent.trim()}`,
);
assert.ok(
envFileContent.includes('has space/gsd-core/bin'),
`Expected envfile to contain the spaced bin dir, got: ${envFileContent.trim()}`,
);
assert.ok(
envFileContent.includes(':"$PATH"'),
`Expected envfile to contain :"$PATH" (double-quoted, expands at source time), got: ${envFileContent.trim()}`,
);
// Windows Git Bash (msys2) does not honor Node's chmod exec bit for PATH-executing
// extension-less scripts; the env-file persistence above is the cross-platform proof.
// Global installs on Windows are covered by npm's generated bin shim.
if (process.platform !== 'win32') {
// Simulate a LATER fresh block that SOURCES the env file to get gsd_run on PATH.
// The later shell does NOT have the bin dir on PATH beforehand — it only gets it
// by sourcing the env file. We use a minimal PATH (no temp bin dir pre-injected).
const stdout = execFileSync('bash', ['-c', '. "$CLAUDE_ENV_FILE"; gsd_run hello'], {
encoding: 'utf8',
env: {
CLAUDE_ENV_FILE: envFile,
PATH: process.env.PATH,
},
});
assert.ok(
stdout.includes('GSD_RUN_STUB:hello'),
`Expected stdout to contain "GSD_RUN_STUB:hello" after sourcing env file, got: ${stdout.trim()}`,
);
}
} finally {
cleanup(baseParent);
}
});
});
// ─── Agent parity — runtime-launcher-parity — agents (#1041) ─────────────────
describe('runtime-launcher-parity — agents (#1041)', () => {
// ─── (B-agents) Exactly ONE canonical preamble per using agent file ────────
test('(B-agents) each agent .md using gsd_run contains exactly ONE canonical preamble, before the first gsd_run call', () => {
const preamble = expectedPreamble();
const preambleStr = preamble.join('\n');
const files = collectAgentFiles();
assert.ok(files.length > 0, 'expected at least one agent .md file');
const violations = [];
for (const f of files) {
const rel = path.relative(AGENTS_DIR, f);
const content = fs.readFileSync(f, 'utf8');
const blocks = extractShellBlocks(content);
// Collect all block lines in document order for flat analysis
const allBlockLines = [];
for (const blk of blocks) {
allBlockLines.push(...blk.lines);
}
// Does this file use gsd_run at all?
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
if (!fileHasGsdRun) continue; // agents without gsd_run are not checked
// Count preamble occurrences across all shell content of this file
const allContent = allBlockLines.join('\n');
let preambleCount = 0;
let searchPos = 0;
while (true) {
const idx = allContent.indexOf(preambleStr, searchPos);
if (idx === -1) break;
preambleCount++;
searchPos = idx + preambleStr.length;
}
if (preambleCount !== 1) {
violations.push(
`${rel}: expected exactly 1 canonical preamble occurrence in bash blocks, found ${preambleCount}. ` +
`Run \`node scripts/sync-runtime-launcher.cjs\` to fix.`,
);
continue;
}
// Verify preamble appears BEFORE the first gsd_run call (in document order)
const preamblePos = allContent.indexOf(preambleStr);
const firstGsdRunPos = allContent.search(/\bgsd_run\b/);
// The first gsd_run WITHIN the preamble itself (the function definition) is fine.
// Simple check: preamble starts at or before the first gsd_run occurrence.
if (preamblePos > firstGsdRunPos) {
violations.push(
`${rel}: preamble appears AFTER the first gsd_run reference — it must precede all gsd_run calls.`,
);
}
}
assert.deepStrictEqual(
violations,
[],
'Agent files with gsd_run calls have wrong preamble count or ordering:\n' +
violations.join('\n---\n'),
);
});
});