test: run assembled setup harness scripts from a temp file, not bash -c argv (Windows MSYS2 8 KB truncation)

windows-free-tests (run 33907177851) failed in
test/setup-alias-name-uniqueness.test.ts with
  bash: -c: line 178: unexpected EOF while looking for matching `'
The harness slices functions out of `setup` and passed the joined script as
one `bash -c` argv element. The ownership gate grew that script from 6.7 KB
to 15.7 KB, and on Windows bash is an MSYS2 program: when its parent is a
non-MSYS process (bun), msys-2.0.dll's build_argv() runs any argument
containing `?*["'(){}` through globify()/glob(), which copies the pattern
into a fixed `Char patbuf[8192]` and silently stops after 8192 - MB_CUR_MAX
(8186 chars under C.UTF-8); GLOB_NOCHECK then returns the truncated text as
the argument. Character 8186 lands inside the single-quoted sed token on
line 178. Rebuilding the exact script with CI path shapes and cutting it at
8186-8190 characters reproduces the identical message locally; cmd.exe's
8191-UTF-16 cap and CreateProcess's 32767 do not fit the evidence.

Fix: test/helpers/bash-script.ts writes the script to a temp file and runs
`bash <path>` — a short glob-free argument that never enters globify. Every
setup harness that assembled a script for `bash -c` (11 files, 22 sites)
uses it; timeouts and env are preserved verbatim, spawn/timeout errors are
appended to stderr, temp cleanup is best-effort. `spawnSync('bash',
[<Windows absolute path>])` already passes on windows-latest in setup-help,
uninstall-windows-copies and the migration tests. The Windows-curated list
is byte-identical before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-04 23:05:59 +00:00
co-authored by Claude Fable 5.1
parent e64bab7a00
commit 1204828699
12 changed files with 92 additions and 42 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* Run an assembled bash script from a TEMP FILE — never via `bash -c <argv>`.
*
* Why: the setup harnesses slice functions out of `setup` and join them into
* one script. On Windows, bash is an MSYS2 program; when its parent is a
* non-MSYS process (bun), msys-2.0.dll's build_argv() runs every argument
* containing any of `?*["'(){}` through globify()/glob(). glob() copies the
* pattern into a fixed `Char patbuf[8192]` and silently stops after
* 8192 - MB_CUR_MAX (8186 characters under C.UTF-8); GLOB_NOCHECK then hands
* the truncated text to bash as the argument. Observed on windows-free-tests
* when the alias harness grew from 6.7 KB to 15.7 KB: the `-c` script was cut
* inside a single-quoted token on line 178 ("unexpected EOF while looking for
* matching `'"). A short, glob-character-free file path never enters
* globify, and bash reads the file's bytes directly, so quoting and encoding
* are never re-parsed by any argument layer. The same ~8186-char ceiling
* applies to any MSYS tool (sh, sed, awk, grep) spawned from bun on Windows
* with a long single argument.
*
* `timeout` is always set (test/spawnsync-timeout-tripwire.test.ts).
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface BashScriptResult {
status: number;
stdout: string;
stderr: string;
signal: NodeJS.Signals | null;
}
export interface BashScriptOptions {
timeout?: number;
env?: NodeJS.ProcessEnv;
cwd?: string;
}
export function runBashScript(script: string, opts: BashScriptOptions = {}): BashScriptResult {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bash-script-'));
const file = path.join(dir, 'script.sh');
// LF only: a stray CR would reach bash as part of a token.
fs.writeFileSync(file, script.replace(/\r\n/g, '\n'));
try {
const r = spawnSync('bash', [file], {
encoding: 'utf-8',
timeout: opts.timeout ?? 60_000,
...(opts.env ? { env: opts.env } : {}),
...(opts.cwd ? { cwd: opts.cwd } : {}),
});
// A spawn failure (bash missing) or a timeout kill has no bash stderr of
// its own; surface the cause instead of a bare status -1.
const stderr = (r.stderr ?? '') + (r.error ? `\n[spawn] ${r.error.message}` : '');
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr, signal: r.signal ?? null };
} finally {
// Best-effort: an AV scanner or indexer still holding script.sh on
// Windows must never turn a good result into an unlink error.
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort temp cleanup */ }
}
}
+3 -3
View File
@@ -16,7 +16,7 @@
* corrupted the generated SKILL.md — the source files must stay byte-intact.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -74,7 +74,7 @@ beforeAll(() => {
installOnce,
installOnce,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
const result = runBashScript(script, { timeout: 60_000 });
if (result.status !== 0) {
throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`);
}
@@ -168,7 +168,7 @@ describe('alias installs are rewritten copies (#2511, #2201)', () => {
extractFn('link_claude_root_skill_alias'),
`link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const result = runBashScript(script, { timeout: 30_000 });
expect(result.status).toBe(0);
const aliasSkill = path.join(aliasDir, 'SKILL.md');
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -9,7 +9,7 @@ const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
// Run a bash snippet, return {stdout, stderr, status}.
function runBash(script: string): { stdout: string; stderr: string; status: number } {
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const r = runBashScript(script, { timeout: 30_000 });
return { stdout: r.stdout || '', stderr: r.stderr || '', status: r.status ?? -1 };
}
+2 -2
View File
@@ -21,7 +21,7 @@
* down to uselessness.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -82,7 +82,7 @@ beforeAll(() => {
extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
const result = runBashScript(script, { timeout: 60_000 });
if (result.status !== 0) {
throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`);
}
+2 -5
View File
@@ -8,7 +8,7 @@
* skills must stay.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -72,10 +72,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
extractFn('cleanup_old_claude_symlinks'),
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
].join('\n');
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf-8',
timeout: 5000,
});
const result = runBashScript(script, { timeout: 5000 });
const names = fs.existsSync(skills)
? fs.readdirSync(skills).sort()
: [];
+5 -4
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
@@ -85,7 +86,7 @@ describe('setup: Conductor worktree guard', () => {
echo "LINKED"
fi
`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const result = runBashScript(script, { timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('SKIP');
// No child symlink leaked.
@@ -120,7 +121,7 @@ describe('setup: Conductor worktree guard', () => {
echo "LINKED"
fi
`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const result = runBashScript(script, { timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('LINKED');
expect(fs.lstatSync(dest).isSymbolicLink()).toBe(true);
@@ -159,7 +160,7 @@ describe('setup: Conductor worktree guard', () => {
echo "LINKED"
fi
`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const result = runBashScript(script, { timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('LINKED');
expect(fs.readlinkSync(dest)).toBe(source);
@@ -191,7 +192,7 @@ describe('setup: Conductor worktree guard', () => {
fi
echo "skip=$_SKIP_CLAUDE_REGISTER"
`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
const result = runBashScript(script, { timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('skip=0');
} finally {
+2 -6
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
@@ -137,11 +137,7 @@ describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior
'echo "INSTALLED=$EMOJI_FONT_INSTALLED"',
].join('\n');
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf-8',
timeout: 10000,
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
const result = runBashScript(script, { timeout: 10000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
const out = result.stdout ?? '';
return {
exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1),
+2 -5
View File
@@ -9,7 +9,7 @@
* test/setup-cleanup-orphans.test.ts.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -53,10 +53,7 @@ function mkTree(): { tmp: string; skills: string; payload: string } {
return { tmp, skills, payload };
}
function bash(lines: string[], tmp: string) {
const r = spawnSync('bash', ['-c', lines.join('\n')], {
encoding: 'utf-8', timeout: 10_000,
env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') },
});
const r = runBashScript(lines.join('\n'), { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') } });
// An extracted function calling a helper this harness forgot to extract must
// fail loudly, not degrade into "foreign, skipped".
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
+6 -5
View File
@@ -17,6 +17,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -403,7 +404,7 @@ function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr
emoji,
'echo "REACHED_END=1"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 });
const r = runBashScript(script, { timeout: 10_000 });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
@@ -450,7 +451,7 @@ function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelud
tail,
'echo "REACHED_END=1"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 });
const r = runBashScript(script, { timeout: 10_000 });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : '';
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv };
@@ -582,7 +583,7 @@ function runLinker(opts: {
`link_claude_skill_dirs "${payload}" "${skills}"`,
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } });
const r = runBashScript(script, { timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp };
}
@@ -628,12 +629,12 @@ describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership ma
fs.writeFileSync(path.join(r.skills, 'review', 'SKILL.md'), '---\nname: review\n---\n# mine, hand-written\n');
fs.mkdirSync(path.join(r.skills, 'my-own'));
fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n');
const flip = spawnSync('bash', ['-c', [
const flip = runBashScript([
'set -e', 'IS_WINDOWS=1',
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'), extractFn('_backup_skill_md'), '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
extractFn('cleanup_old_claude_symlinks'),
`cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`,
].join('\n')], { encoding: 'utf-8', timeout: 10_000 });
].join('\n'), { timeout: 10_000 });
expect(flip.status).toBe(0);
expect(flip.stdout).toContain('cleaned up old entries:');
expect(flip.stdout).toContain('qa');
+2 -5
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
@@ -95,10 +95,7 @@ describe.skipIf(process.platform === 'win32')('setup: _link_or_copy helper — b
const helper = extractHelper();
// IS_WINDOWS must exist as a shell-readable var before sourcing.
const script = `IS_WINDOWS=${isWindows}\n${helper}\n_link_or_copy "${src}" "${dst}"\n`;
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf-8',
timeout: 5000,
});
const result = runBashScript(script, { timeout: 5000 });
const lst = fs.lstatSync(dst, { throwIfNoEntry: false });
return {
ok: result.status === 0,
+2 -2
View File
@@ -16,7 +16,7 @@
* factory/opencode can't silently regress.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -165,7 +165,7 @@ function runInstaller(
...fns.map(extractFn),
invocation,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
const r = runBashScript(script, { timeout: 15_000 });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
+4 -3
View File
@@ -14,6 +14,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -86,7 +87,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
extractFn(src, '_swap_in_render'),
`_swap_in_render "${live}" "${fresh}"`,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
const r = runBashScript(script, { timeout: 15_000 });
expect(r.status).toBe(0);
// Live dir now serves the fresh render at the SAME path (links into
// it stay valid), tmp and .old are gone.
@@ -126,7 +127,7 @@ describe(':user render targets the out-dir, never the checkout (#2569)', () => {
' echo "render failed — previous render left in place" >&2',
'fi',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
const r = runBashScript(script, { timeout: 15_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n');
// The installed symlink still resolves — the skill set did not vanish.
@@ -191,7 +192,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
`link_claude_skill_dirs "${src}" "${skills}"`,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
const r = runBashScript(script, { timeout: 15_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha');