From 3310822b7e319bd50321584099af16dda7b1d5db Mon Sep 17 00:00:00 2001 From: Nikhilesh Nanduri Date: Mon, 25 May 2026 09:41:24 +0530 Subject: [PATCH 1/2] fix(codex): add missing closing fence in resumed-session bash block The bash block opened at the "For a resumed session" section was missing its closing ``` fence. Prose text with backtick-quoted identifiers (e.g. `SESSION_ID:`) was being parsed as shell code, causing bash -n to report "unexpected EOF while looking for matching `". Co-Authored-By: Claude Sonnet 4.6 --- codex/SKILL.md | 1 + codex/SKILL.md.tmpl | 1 + 2 files changed, 2 insertions(+) diff --git a/codex/SKILL.md b/codex/SKILL.md index 24331dde34..b46acb7a77 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -1414,6 +1414,7 @@ elif [ "$_CODEX_EXIT" != "0" ]; then head -20 "$TMPERR" 2>/dev/null | sed 's/^/ /' || true _gstack_codex_log_event "codex_nonzero_exit" "consult-resume:$_CODEX_EXIT" fi +``` 5. Capture session ID from the streamed output. The parser prints `SESSION_ID:` from the `thread.started` event. Save it for follow-ups: diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index 333de7d8d5..4dcaa0b552 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -561,6 +561,7 @@ elif [ "$_CODEX_EXIT" != "0" ]; then head -20 "$TMPERR" 2>/dev/null | sed 's/^/ /' || true _gstack_codex_log_event "codex_nonzero_exit" "consult-resume:$_CODEX_EXIT" fi +``` 5. Capture session ID from the streamed output. The parser prints `SESSION_ID:` from the `thread.started` event. Save it for follow-ups: From 1754e15cc0d362b31a1df335dfcca47d38052b0c Mon Sep 17 00:00:00 2001 From: Nikhilesh Nanduri Date: Tue, 26 May 2026 20:31:01 +0530 Subject: [PATCH 2/2] feat(test): add bash syntax validation gate test for all SKILL.md blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test/skill-bash-syntax.test.ts — a gate-tier free test that extracts all bash code blocks from all 53 generated SKILL.md files and runs bash -n on each. Covers 1327+ blocks in ~4s with no API calls. Fixes #1667. Angle-bracket placeholders like are preprocessed to PLACEHOLDER before validation so template markers don't produce false positives. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 ++++ VERSION | 2 +- package.json | 2 +- test/skill-bash-syntax.test.ts | 127 +++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 test/skill-bash-syntax.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbc82f998..ee31c0c754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.48.4.0] - 2026-05-28 + +**Bash syntax validation now catches broken shell code in SKILL.md files before it ships to users.** + +Every generated SKILL.md contains bash code blocks that agents execute. A typo like `2/dev/null` instead of `2>/dev/null`, or a missing closing code fence that swallows prose as bash, would pass all tests and break silently in the field. `test/skill-bash-syntax.test.ts` extracts all bash blocks across all 53 skill files and runs `bash -n` (parse-only) on each. The test also found and fixed a real bug: an unclosed fence in `codex/SKILL.md` that caused prose text to be parsed as shell code. + +### Itemized changes + +#### Added +- `test/skill-bash-syntax.test.ts`: gate-tier free test; `bash -n` on all bash blocks in all 53 SKILL.md files; angle-bracket placeholders preprocessed to avoid false positives from stdin-redirect syntax + +#### Fixed +- `codex/SKILL.md` + `codex/SKILL.md.tmpl`: missing closing fence on the resumed-session bash block + ## [1.48.0.0] - 2026-05-26 ## **Agents stop dropping AskUserQuestion options when there are 5+.** A new canonical preamble rule + runtime gate makes Conductor's 4-option cap a split-or-batch decision, not a silent trim. diff --git a/VERSION b/VERSION index 01934fdf4c..397dd4cce8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.48.0.0 +1.48.4.0 diff --git a/package.json b/package.json index eb77faa516..2b47510bc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.48.0.0", + "version": "1.48.4.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/skill-bash-syntax.test.ts b/test/skill-bash-syntax.test.ts new file mode 100644 index 0000000000..1161f2fbca --- /dev/null +++ b/test/skill-bash-syntax.test.ts @@ -0,0 +1,127 @@ +/** + * Bash syntax validation for SKILL.md code blocks (gate, free). + * + * Extracts every ```bash...``` block from all generated SKILL.md files and + * runs `bash -n` (parse-only, no execution) on each one. Catches unclosed + * code fences, broken redirects (e.g. `2/dev/null` instead of `2>/dev/null`), + * unclosed strings, and other syntactic mistakes before they reach users. + * + * Preprocessing: angle-bracket placeholder tokens like are + * replaced with the bareword PLACEHOLDER before validation. Without this + * substitution, `` is parsed as a shell redirect (read stdin from + * file "word>") and would produce false positive errors on intentional + * template placeholders that agents fill in at runtime. + * + * Covers all 53 SKILL.md files discovered via discoverSkillFiles(). + * Runs in < 2s with no network or API calls. + */ + +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']); + +function discoverSkillFiles(root: string): string[] { + const subdirs = fs.readdirSync(root, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.') && !SKIP_DIRS.has(d.name)) + .map(d => d.name); + + const results: string[] = []; + if (fs.existsSync(path.join(root, 'SKILL.md'))) { + results.push('SKILL.md'); + } + for (const dir of subdirs) { + const rel = `${dir}/SKILL.md`; + if (fs.existsSync(path.join(root, rel))) { + results.push(rel); + } + } + return results; +} + +interface BashBlock { + code: string; + lineNo: number; // 1-indexed line number where the block content starts +} + +function extractBashBlocks(content: string): BashBlock[] { + const lines = content.split('\n'); + const blocks: BashBlock[] = []; + let inBash = false; + let blockLines: string[] = []; + let startLine = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!inBash && line.trimStart().startsWith('```bash')) { + inBash = true; + blockLines = []; + startLine = i + 2; // +1 for 1-index, +1 to skip the fence line itself + continue; + } + if (inBash && line.trimStart() === '```') { + blocks.push({ code: blockLines.join('\n'), lineNo: startLine }); + inBash = false; + continue; + } + if (inBash) { + blockLines.push(line); + } + } + return blocks; +} + +// Replace tokens with the bareword PLACEHOLDER to prevent +// bash from interpreting them as stdin redirects, which produce false errors. +function preprocess(code: string): string { + return code.replace(/<[A-Za-z][A-Za-z0-9_.-]*>/g, 'PLACEHOLDER'); +} + +function checkBashSyntax(code: string): string | null { + const result = Bun.spawnSync(['bash', '-n'], { + stdin: Buffer.from(code), + stdout: 'pipe', + stderr: 'pipe', + }); + if (result.exitCode !== 0) { + return result.stderr.toString().trim(); + } + return null; +} + +describe('bash syntax validation in SKILL.md code blocks', () => { + const skillFiles = discoverSkillFiles(ROOT); + + // Sanity: we should find at least 10 skill files + test('discovers skill files', () => { + expect(skillFiles.length).toBeGreaterThanOrEqual(10); + }); + + for (const relPath of skillFiles) { + test(`${relPath} — all bash blocks pass bash -n`, () => { + const content = fs.readFileSync(path.join(ROOT, relPath), 'utf-8'); + const blocks = extractBashBlocks(content); + + const errors: string[] = []; + for (const { code, lineNo } of blocks) { + const preprocessed = preprocess(code); + const errMsg = checkBashSyntax(preprocessed); + if (errMsg !== null) { + // Trim bash's "bash: line N:" prefix and replace with our own location + const cleaned = errMsg.replace(/^bash: line \d+: /, ''); + errors.push(` ~line ${lineNo}: ${cleaned}`); + } + } + + if (errors.length > 0) { + throw new Error( + `${relPath}: ${errors.length} bash block(s) failed syntax check:\n` + + errors.join('\n') + ); + } + }); + } +});