test(hermetic): fix env-restore leak, isolate machine state, gate absent capabilities

- gstack-memory-helpers.test.ts: env vars were saved in beforeEach but
  restored in afterAll, so the restore leaked the previous test's dead
  temp dir into GSTACK_HOME/GBRAIN_HOME/HOME/PATH for every file that ran
  later (broke gstack-config and make-pdf format-gate in full-suite runs).
  Capture once at collection time instead.
- gstack-config.test.ts: scrub inherited GSTACK_STATE_ROOT/GSTACK_HOME in
  run() unless a test passes them explicitly (defense in depth).
- gbrain-detect-install.test.ts: append a bun-only shim dir to SAFE_PATH —
  the script's '#!/usr/bin/env -S bun run' shebang needs bun on PATH, but
  ~/.bun/bin also carries a real gbrain on dev machines.
- gen-skill-docs / openclaw-native-skills: skipIf(!Bun.YAML) — parser
  lands in bun 1.3; CI pins bun latest so strict-YAML coverage remains.
- codex-resume-flag-semantics: availability probe upgraded from
  'which codex' to 'codex --version' exit 0 (half-installed wrapper
  resolved but could not execute).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
t 2026-07-10 15:22:46 -07:00
parent 25f4e1a649
commit e60e9924e2
6 changed files with 67 additions and 20 deletions

View File

@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
let stateDir: string;
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
// The script resolves GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR.
// Scrub the two higher-priority overrides from the inherited env so a
// leaked GSTACK_HOME/GSTACK_STATE_ROOT from another test file (or the
// operator shell) can never redirect reads/writes away from this test's
// temp stateDir.
const env: Record<string, string | undefined> = {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
};
if (!('GSTACK_STATE_ROOT' in extraEnv)) delete env.GSTACK_STATE_ROOT;
if (!('GSTACK_HOME' in extraEnv)) delete env.GSTACK_HOME;
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
env,
stdout: 'pipe',
stderr: 'pipe',
});

View File

@ -15,8 +15,21 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
// "On PATH" is not enough: a broken install (e.g. the npm wrapper missing its
// platform binary @openai/codex-darwin-arm64) resolves via `which` but can't
// answer any subcommand, so every assertion would fail on wrapper stack traces
// instead of real flag-semantics regressions. Probe `codex --version` and only
// run when the CLI actually executes; a working codex still gets the full
// assertions.
const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8' }).stdout.trim();
const codexAvailable = codexPath.length > 0;
const codexRuns =
codexPath.length > 0 &&
spawnSync('codex', ['--version'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10_000,
}).status === 0;
const codexAvailable = codexRuns;
describe.skipIf(!codexAvailable)(
'codex exec resume — flag semantics (live CLI smoke; closes #1270 regex-only gap)',

View File

@ -25,7 +25,15 @@ const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install');
// dirs — this keeps `gbrain` out of PATH deterministically across dev machines
// while still finding jq, git, curl, sed, cat, etc. Each test can prepend a
// fake-gbrain dir when it wants to simulate presence.
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
//
// One wrinkle: bin/gstack-gbrain-detect's shebang is `#!/usr/bin/env -S bun run`,
// so the child's PATH must contain `bun`. On dev machines bun usually lives in
// ~/.bun/bin, which we deliberately exclude (it also holds a real `gbrain` from
// `bun link`). Expose ONLY the running bun interpreter through a shim dir,
// appended last so system dirs still win for everything else.
const BUN_SHIM_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-shim-'));
fs.symlinkSync(process.execPath, path.join(BUN_SHIM_DIR, 'bun'));
const SAFE_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:${BUN_SHIM_DIR}`;
let tmpHome: string;
let tmpHomeReal: string;

View File

@ -177,6 +177,13 @@ describe('gen-skill-docs', () => {
// whose plain `description:` scalar contains an interior ": " (read as a nested
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
// not just string-check that name:/description: exist.
//
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
// parser (the repo is deliberately zero-dep for YAML). CI pins
// `bun-version: latest`, so coverage is guaranteed there — on an old local
// bun we skip rather than false-fail.
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
function frontmatterBlock(content: string): string {
expect(content.startsWith('---\n')).toBe(true);
const end = content.indexOf('\n---', 4);
@ -184,7 +191,7 @@ describe('gen-skill-docs', () => {
return content.slice(4, end);
}
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
test.skipIf(!hasBunYaml)('every generated SKILL.md frontmatter parses as strict YAML', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const fm = frontmatterBlock(content);
@ -196,7 +203,7 @@ describe('gen-skill-docs', () => {
}
});
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
test.skipIf(!hasBunYaml)('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {

View File

@ -249,11 +249,14 @@ body
// ── withErrorContext ───────────────────────────────────────────────────────
describe("withErrorContext", () => {
let savedHome: string | undefined;
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
// beforeEach clobbers the real value with the previous test's temp dir
// after test #1, so the afterAll restore leaked a dead temp path into
// process.env for every test file that ran after this one.
const savedHome = process.env.GSTACK_HOME;
let testHome: string;
beforeEach(() => {
savedHome = process.env.GSTACK_HOME;
testHome = mkdtempSync(join(tmpdir(), "gstack-test-home-"));
process.env.GSTACK_HOME = testHome;
});
@ -312,18 +315,20 @@ describe("withErrorContext", () => {
// ── detectEngineTier ───────────────────────────────────────────────────────
describe("detectEngineTier", () => {
let savedHome: string | undefined;
let savedGbrainHome: string | undefined;
let savedRealHome: string | undefined;
let savedPath: string | undefined;
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
// beforeEach clobbers the real values with the previous test's temp dirs
// after test #1, so the afterAll restore leaked dead gstack-test-engine-*
// temp paths into GSTACK_HOME / GBRAIN_HOME / HOME for every test file
// that ran after this one (broke gstack-config + make-pdf format-gate in
// full-suite runs).
const savedHome = process.env.GSTACK_HOME;
const savedGbrainHome = process.env.GBRAIN_HOME;
const savedRealHome = process.env.HOME;
const savedPath = process.env.PATH;
let testHome: string;
let testGbrainHome: string;
beforeEach(() => {
savedHome = process.env.GSTACK_HOME;
savedGbrainHome = process.env.GBRAIN_HOME;
savedRealHome = process.env.HOME;
savedPath = process.env.PATH;
testHome = mkdtempSync(join(tmpdir(), "gstack-test-engine-"));
testGbrainHome = mkdtempSync(join(tmpdir(), "gstack-test-gbrain-"));
process.env.GSTACK_HOME = testHome;

View File

@ -18,8 +18,14 @@ function extractFrontmatter(content: string): string {
return content.slice(4, fmEnd);
}
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
// parser (the repo is deliberately zero-dep for YAML). CI pins
// `bun-version: latest`, so coverage is guaranteed there — on an old local
// bun we skip rather than false-fail.
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
describe('OpenClaw native skills', () => {
test('frontmatter parses as YAML and keeps only name + description', () => {
test.skipIf(!hasBunYaml)('frontmatter parses as YAML and keeps only name + description', () => {
for (const skill of OPENCLAW_NATIVE_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
const frontmatter = extractFrontmatter(content);