mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
fix(mktemp): move the X-run to the end of every temp-file template (BSD/busybox safe)
BSD mktemp (macOS) does not substitute an X-run that has a suffix after it: `mktemp "$TMP_ROOT/codex-err-XXXXXX.txt"` creates a LITERAL codex-err-XXXXXX.txt on the first call (exit 0) and every later call fails with `mkstemp failed: File exists` — so /codex breaks from the SECOND run on every Mac, masquerading as a model stall. busybox mktemp (Alpine) rejects the template on the first run. Fixes #2091, #2370. Union of both community fixes, compared at the diff level: - PR #2372: all 11 source sites with a suffix after the X-run — codex SKILL.md.tmpl (5), claude SKILL.md.tmpl (3), bin/gstack-developer-profile (2, suffix folded into the prefix: .json.tmp.XXXXXX), and the office-hours codex pass in scripts/resolvers/review.ts (1). - PR #2103: the second half of #2091 — bin/gstack-paths now strips the trailing slash from TMP_ROOT at the source (macOS $TMPDIR ends in `/`), plus runtime tests pinning that normalization. New repo-wide tripwire in test/regression-issue2091-bsd-mktemp.test.ts: every .tmpl, every SKILL.md, and every scripts/resolvers/*.ts is swept — no mktemp template may carry a suffix after the X-run, with a self-test so the detector can't be quietly blinded. Generated SKILL.md files regenerated via gen:skill-docs in this commit. Contributed by @ShuratCode (PR #2103) and @noron12234 (PR #2372); PR #2285 by @cathrynlavery covered a subset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a4daa352d6
commit
7a8e39d2cb
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Regression tests for issue #2091 / #2370 — mktemp templates fail on macOS
|
||||
* (BSD mktemp) and Alpine (busybox mktemp) when the X placeholder run is not
|
||||
* the LAST thing in the template.
|
||||
*
|
||||
* Two compounding bugs, both in gstack:
|
||||
*
|
||||
* 1. Suffix after the placeholder. Skills used templates like
|
||||
* `mktemp "$TMP_ROOT/codex-err-XXXXXX.txt"`. GNU mktemp tolerates a suffix
|
||||
* after the X run; BSD mktemp (macOS) does NOT — it does not substitute the
|
||||
* X's at all, so call #1 creates a LITERAL `codex-err-XXXXXX.txt` (exit 0)
|
||||
* and a later call (a second /codex run, a stale leftover, or a concurrent
|
||||
* worktree) fails with `mkstemp failed: File exists` and aborts the review.
|
||||
* busybox mktemp (Alpine) rejects the template outright on the FIRST run.
|
||||
* Fixed by moving the placeholder to the END of every mktemp template.
|
||||
*
|
||||
* 2. Trailing slash in TMP_ROOT. `bin/gstack-paths` emitted TMP_ROOT straight
|
||||
* from $TMPDIR, which on macOS ends in `/` (e.g. /var/folders/.../T/),
|
||||
* producing a double-slash path (`…/T//codex-err-…`). Fixed by stripping
|
||||
* the trailing slash at the source so every consumer benefits, not just
|
||||
* /codex.
|
||||
*
|
||||
* Bug 1's tripwire is repo-wide: it sweeps EVERY .tmpl, every SKILL.md, and
|
||||
* every scripts/resolvers/*.ts (the sources that feed generated skills), so a
|
||||
* new skill can't re-introduce the suffix shape anywhere.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const PATHS_BIN = path.join(ROOT, 'bin', 'gstack-paths');
|
||||
|
||||
// ── Bug 1: BSD mktemp requires the X placeholder at the END of the template ──
|
||||
// Swept across all skills (templates AND generated output) plus the resolver
|
||||
// modules that feed generated sections, so neither a hand-edit nor a regen
|
||||
// drift can reopen the bug.
|
||||
|
||||
/** Directories that are not gstack-authored skill/template sources. */
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'.claude',
|
||||
'.agents',
|
||||
'.factory',
|
||||
'fixtures', // test/fixtures — goldens snapshot generated output separately
|
||||
]);
|
||||
|
||||
function collectScannedFiles(dir: string, out: string[]): void {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
// Symlinks (e.g. connect-chrome → open-gstack-browser) would double-count
|
||||
// or escape the tree; the link target is scanned via its real path.
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) {
|
||||
collectScannedFiles(full, out);
|
||||
} else if (
|
||||
entry.name === 'SKILL.md' ||
|
||||
entry.name.endsWith('.tmpl') ||
|
||||
(full.includes(path.join('scripts', 'resolvers')) && entry.name.endsWith('.ts'))
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every mktemp template token on a line and return the ones whose
|
||||
* X-run (4+ X's) is NOT the final character run. Tokens are whitespace-split
|
||||
* words after `mktemp`, stripped of shell/TS quoting and closers, so
|
||||
* `$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")` yields the offending
|
||||
* `$TMP_ROOT/codex-err-XXXXXX.txt`.
|
||||
*/
|
||||
function offendingTemplatesOnLine(line: string): string[] {
|
||||
const idx = line.indexOf('mktemp');
|
||||
if (idx === -1) return [];
|
||||
const offenders: string[] = [];
|
||||
for (const raw of line.slice(idx).split(/\s+/)) {
|
||||
const token = raw.replace(/^["'(`]+|[)"'`;,\\}]+$/g, '');
|
||||
if (!/X{4,}/.test(token)) continue;
|
||||
if (!/X{4,}$/.test(token)) offenders.push(token);
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
describe('#2091/#2370 bug 1: every mktemp template is BSD-safe (X placeholder at end)', () => {
|
||||
const files: string[] = [];
|
||||
collectScannedFiles(ROOT, files);
|
||||
|
||||
test('scan sweep finds the known mktemp call sites (not vacuous)', () => {
|
||||
// Guards against the walker silently matching nothing after a refactor.
|
||||
const withMktemp = files.filter((f) => fs.readFileSync(f, 'utf-8').includes('mktemp'));
|
||||
expect(withMktemp.length).toBeGreaterThanOrEqual(5);
|
||||
expect(withMktemp).toContain(path.join(ROOT, 'codex', 'SKILL.md.tmpl'));
|
||||
expect(withMktemp).toContain(path.join(ROOT, 'codex', 'SKILL.md'));
|
||||
expect(withMktemp).toContain(path.join(ROOT, 'scripts', 'resolvers', 'review.ts'));
|
||||
});
|
||||
|
||||
test('no .tmpl, SKILL.md, or resolver carries a suffix after the X-run', () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
for (const token of offendingTemplatesOnLine(line)) {
|
||||
violations.push(`${path.relative(ROOT, file)}:${i + 1} — ${token}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('the offender-detector itself still detects the original bug shapes', () => {
|
||||
// Self-test so a regex tweak can't quietly blind the tripwire.
|
||||
expect(offendingTemplatesOnLine('TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")')).toEqual([
|
||||
'$TMP_ROOT/codex-err-XXXXXX.txt',
|
||||
]);
|
||||
expect(
|
||||
offendingTemplatesOnLine('TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.XXXXXX.tmp")'),
|
||||
).toEqual(['$GSTACK_HOME/developer-profile.json.XXXXXX.tmp']);
|
||||
expect(offendingTemplatesOnLine('RESP_FILE=$(mktemp /tmp/gstack-claude-response-XXXXXX.json)')).toEqual([
|
||||
'/tmp/gstack-claude-response-XXXXXX.json',
|
||||
]);
|
||||
// Fixed shapes pass.
|
||||
expect(offendingTemplatesOnLine('TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX")')).toEqual([]);
|
||||
expect(offendingTemplatesOnLine('ALL_JSONL=$(mktemp -t autoplan-tasks.XXXXXXXX)')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bug 2: gstack-paths normalizes TMP_ROOT (no trailing slash) ──────────────
|
||||
// Mirrors the invocation contract used by test/gstack-paths.test.ts: the helper
|
||||
// is always sourced from a bash block, so we run it via `bash`.
|
||||
function tmpRoot(env: Record<string, string | undefined>): string {
|
||||
const result = spawnSync('bash', [PATHS_BIN], {
|
||||
env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record<string, string>,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gstack-paths failed (status ${result.status}): ${result.stderr}`);
|
||||
}
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
if (line.startsWith('TMP_ROOT=')) return line.slice('TMP_ROOT='.length);
|
||||
}
|
||||
throw new Error('gstack-paths did not emit TMP_ROOT');
|
||||
}
|
||||
|
||||
describe('#2091 bug 2: gstack-paths strips the trailing slash from TMP_ROOT', () => {
|
||||
test('macOS-style TMPDIR with trailing slash → trailing slash stripped', () => {
|
||||
expect(tmpRoot({ TMPDIR: '/var/folders/ab/T/', HOME: '/tmp/h' })).toBe('/var/folders/ab/T');
|
||||
});
|
||||
|
||||
test('TMP (Windows/container fallback) with trailing slash is also normalized', () => {
|
||||
expect(tmpRoot({ TMP: '/tmp/y/', HOME: '/tmp/h' })).toBe('/tmp/y');
|
||||
});
|
||||
|
||||
test('a path without a trailing slash is left unchanged', () => {
|
||||
expect(tmpRoot({ TMPDIR: '/tmp/x', HOME: '/tmp/h' })).toBe('/tmp/x');
|
||||
});
|
||||
|
||||
test('a bare "/" does not collapse to empty', () => {
|
||||
expect(tmpRoot({ TMPDIR: '/', HOME: '/tmp/h' })).toBe('/');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user