Merge origin/main (v1.80.0.0) into consolidate-browser-skills-into-aside; queue-advance release to v1.81.0.0

Both sides claimed v1.80.0.0, so VERSION, package.json and the agents digest
auto-merged without a conflict; the release is re-versioned to the next free
MINOR slot (bin/gstack-next-version), the size-budget baseline renamed to
match, and the CHANGELOG carries the Aside-first entry above main's.

Two semantic conflicts hidden in the clean setup auto-merge are resolved here:
- _prune_stale_generated deleted a REAL host directory on banner-only proof;
  main's #2119 gate makes that weak proof file-scoped, so real dirs now go
  through _cleanup_weak_dir (SKILL.md, marker and our links only).
- _browser_hint and the Chromium bootstrap summary now consult _PW_FAIL_REASON
  and Aside presence, so they never promise a bundled browser that cannot
  launch and never tell an Aside user their browser skills are gone.
README, TESTING_INTERNALS and TODOS wording reconciled with the merged tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-06 05:22:04 +00:00
co-authored by Claude Fable 5.1
43 changed files with 6157 additions and 154 deletions
@@ -1,5 +1,5 @@
{
"tag": "v1.80.0.0",
"tag": "v1.81.0.0",
"capturedAt": "2026-09-05T20:36:18.449Z",
"capturedFromCommit": "fe622529",
"capturedFromBranch": "consolidate-browser-skills-into-aside",
+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 */ }
}
}
+2 -2
View File
@@ -478,7 +478,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'prompt',
maxSkeletonBytes: 74_500, // + Aside browser contract for Step 7 canary ({{ASIDE_SETUP}}); measured 73_523
maxSizeRatio: 1.10, // + v1.80 Aside contract + gstack-browser fallback block; measured 1.077
maxSizeRatio: 1.10, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.077
minUnionBytes: 91_000, // Phase 4 wave 1; estimated union ~94.9KB
mustContain: ['readiness', 'merge', 'canary', 'revert', 'staging'],
},
@@ -607,7 +607,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'prompt',
maxSkeletonBytes: 63_500, // + v2.0 {{ASIDE_SETUP}}/{{BROWSE_FALLBACK}} (replaces the browse setup block); measured 61_253
maxSizeRatio: 1.08, // + v1.80 Aside contract + gstack-browser fallback block; measured 1.063
maxSizeRatio: 1.08, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.063
minUnionBytes: 69_500, // measured union 70,385
// 'aside repl' pins the Aside contract; '$B goto' pins the fallback block in the always-loaded skeleton.
mustContain: ['bug', 'aside repl', '$B goto', 'fix', 'Health Score Rubric', 'regression'],
+291 -15
View File
@@ -76,12 +76,45 @@ function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) {
}
}
// The freeze WRITER resolves its state root through bin/gstack-paths, which
// trusts CLAUDE_PLUGIN_DATA only when CLAUDE_PLUGIN_ROOT names gstack; the
// reader mirrors that exact chain (#1459 / #1509). A test standing in for a
// plugin install must supply both, and must neutralize a GSTACK_HOME inherited
// from the shard's process.env (an empty value reads as unset in ${VAR:-}).
function freezeEnv(stateDir: string, extra: Record<string, string> = {}): Record<string, string> {
return { GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: stateDir, CLAUDE_PLUGIN_ROOT: '/plugins/gstack', ...extra };
}
const HOOK_EXTRACT = path.join(ROOT, 'careful', 'bin', 'hook-extract.sh');
const GSTACK_PATHS = path.join(ROOT, 'bin', 'gstack-paths');
/** What the hook helper resolves as the state root under a given env. */
function hookStateRoot(env: Record<string, string>): string {
const r = spawnSync('bash', ['-c', `. "${HOOK_EXTRACT}" && gstack_hook_state_root`], {
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
});
return r.stdout.trim();
}
/** What bin/gstack-paths resolves as GSTACK_STATE_ROOT under the same env. */
function pathsStateRoot(env: Record<string, string>): string {
const r = spawnSync('bash', ['-c', `eval "$("${GSTACK_PATHS}")" && printf '%s' "$GSTACK_STATE_ROOT"`], {
env: { PATH: process.env.PATH ?? '', ...env }, encoding: 'utf-8', timeout: 5000,
});
return r.stdout.trim();
}
// ============================================================
// Frontmatter hook wiring (#2469 / #1871)
// ============================================================
// Frontmatter hooks run before any runtime variable exists, so a
// ${CLAUDE_SKILL_DIR}-relative command silently never resolves and the guard
// never fires. Every command: line must anchor on $HOME like careful/freeze.
function withEmptyDir(fn: (dir: string) => void) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-empty-'));
try { fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
describe('frontmatter hook command paths', () => {
test.each(['investigate/SKILL.md', 'careful/SKILL.md', 'freeze/SKILL.md', 'guard/SKILL.md'])(
'%s hook commands are $HOME-anchored, never CLAUDE_SKILL_DIR',
@@ -666,6 +699,52 @@ describe('check-careful.sh', () => {
});
});
test('an older hook-extract.sh without gstack_hook_state_root still loads rules from $HOME/.gstack and emits a decision (no set -e death)', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-oldhelper-'));
const carefulBin = path.join(base, 'careful', 'bin');
fs.mkdirSync(carefulBin, { recursive: true });
fs.copyFileSync(CAREFUL_SCRIPT, path.join(carefulBin, 'check-careful.sh'));
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
const start = helper.indexOf('gstack_hook_state_root() {');
const end = helper.indexOf('\n}\n', start) + 3;
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
const fakeHome = path.join(base, 'home');
fs.mkdirSync(path.join(fakeHome, '.gstack'), { recursive: true });
fs.writeFileSync(path.join(fakeHome, '.gstack', 'careful-patterns.txt'), 'terraform\\s+destroy\n');
try {
const { exitCode, output } = runHook(path.join(carefulBin, 'check-careful.sh'), carefulInput('terraform destroy'), { HOME: fakeHome, GSTACK_HOME: '' });
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('plugin install: patterns under CLAUDE_PLUGIN_DATA load when CLAUDE_PLUGIN_ROOT names gstack (same root the writer uses)', () => {
withPatternFile('terraform\\s+destroy\n', (pluginData) => {
withEmptyDir((fakeHome) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
{ HOME: fakeHome, GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
});
});
});
test('GSTACK_HOME outranks CLAUDE_PLUGIN_DATA for careful patterns, exactly as for the freeze file', () => {
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
withEmptyDir((pluginData) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'),
{ GSTACK_HOME: gstackHome, CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_PLUGIN_ROOT: '/plugins/gstack' });
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule');
});
});
});
test('safe commands still allow with a pattern file present', () => {
withPatternFile('terraform\\s+destroy\n', (gstackHome) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('ls -la'), { GSTACK_HOME: gstackHome });
@@ -687,7 +766,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
@@ -699,7 +778,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src/components/Button.tsx'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
@@ -713,7 +792,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/other-project/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
@@ -727,7 +806,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/etc/hosts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
@@ -743,7 +822,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src-old/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
@@ -759,7 +838,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/anywhere/at/all.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
@@ -775,7 +854,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
{ tool_input: {} },
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
@@ -787,7 +866,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output } = runHookRaw(
FREEZE_SCRIPT,
'not json at all {{{{',
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
@@ -803,7 +882,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output, raw } = runHook(
FREEZE_SCRIPT,
freezeInput('/tmp/evil"quoted/x.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(() => JSON.parse(raw)).not.toThrow();
@@ -816,7 +895,7 @@ describe('check-freeze.sh', () => {
const { exitCode, output, raw } = runHook(
FREEZE_SCRIPT,
freezeInput('/tmp/evil\npath.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
freezeEnv(stateDir),
);
expect(exitCode).toBe(0);
expect(() => JSON.parse(raw)).not.toThrow();
@@ -834,11 +913,11 @@ describe('check-freeze.sh', () => {
fs.mkdirSync(boundary, { recursive: true });
try {
withFreezeDir(boundary + '/', (stateDir) => {
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), freezeEnv(stateDir));
expect(inside.exitCode).toBe(0);
expect(inside.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), { CLAUDE_PLUGIN_DATA: stateDir });
const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), freezeEnv(stateDir));
expect(outside.exitCode).toBe(0);
expect(outside.output.hookSpecificOutput?.permissionDecision).toBe('deny');
});
@@ -859,7 +938,7 @@ describe('check-freeze.sh', () => {
fs.copyFileSync(FREEZE_SCRIPT, script);
try {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), { CLAUDE_PLUGIN_DATA: stateDir });
const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('fail closed');
@@ -884,13 +963,13 @@ describe('check-freeze.sh', () => {
fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(boundary, 'link.txt'));
try {
withFreezeDir(boundary + '/', (stateDir) => {
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), freezeEnv(stateDir));
expect(viaLink.exitCode).toBe(0);
expect(viaLink.output.hookSpecificOutput?.permissionDecision).toBe('deny');
// A real in-boundary file is unaffected.
fs.writeFileSync(path.join(boundary, 'real.txt'), 'y');
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), { CLAUDE_PLUGIN_DATA: stateDir });
const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), freezeEnv(stateDir));
expect(real.exitCode).toBe(0);
expect(real.output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
@@ -900,3 +979,200 @@ describe('check-freeze.sh', () => {
});
});
});
// ============================================================
// check-freeze.sh state-root resolution (#1459 / #1509)
// ============================================================
// /freeze writes freeze-dir.txt under the root gstack-paths resolves
// (GSTACK_HOME first). The reader used to read ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}
// — so with GSTACK_HOME set it found no file and ALLOWED everything. A deny-tier
// boundary that fails open is not a boundary; writer and reader now share one
// chain (gstack_hook_state_root in careful/bin/hook-extract.sh).
describe('check-freeze.sh state-root resolution (#1459 / #1509)', () => {
const BOUNDARY = '/Users/dev/project/src/';
const OUTSIDE = '/Users/dev/other-project/index.ts';
test('REGRESSION: freeze file under GSTACK_HOME (HOME has none) denies an outside edit', () => {
withFreezeDir(BOUNDARY, (gstackHome) => {
withEmptyDir((fakeHome) => {
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
});
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
});
});
});
test('GSTACK_HOME wins over CLAUDE_PLUGIN_DATA (matches gstack-paths precedence)', () => {
withFreezeDir(BOUNDARY, (pluginData) => {
withEmptyDir((gstackHome) => {
// The freeze file lives under CLAUDE_PLUGIN_DATA, but GSTACK_HOME is set and
// has none — the writer would have written there, so the reader must look there.
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
freezeEnv(pluginData, { GSTACK_HOME: gstackHome }));
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});
});
test('CLAUDE_PLUGIN_DATA is ignored when CLAUDE_PLUGIN_ROOT is another plugin', () => {
withFreezeDir(BOUNDARY, (pluginData) => {
withEmptyDir((fakeHome) => {
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
freezeEnv(pluginData, { CLAUDE_PLUGIN_ROOT: '/plugins/codex', HOME: fakeHome }));
// Falls through to $HOME/.gstack, which has no freeze file → allow.
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});
});
test('CLAUDE_PLUGIN_DATA is honoured when CLAUDE_PLUGIN_ROOT names gstack', () => {
withFreezeDir(BOUNDARY, (pluginData) => {
withEmptyDir((fakeHome) => {
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), freezeEnv(pluginData, { HOME: fakeHome }));
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
});
});
});
test('gstack_hook_state_root is byte-identical to gstack-paths GSTACK_STATE_ROOT', () => {
const combos: Record<string, string>[] = [
{ HOME: '/home/u', GSTACK_HOME: '/state/x', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/gstack' },
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '/plugins/codex' },
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '/plug/data', CLAUDE_PLUGIN_ROOT: '' },
{ HOME: '/home/u', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
{ HOME: '', GSTACK_HOME: '', CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '' },
];
for (const env of combos) {
expect(hookStateRoot(env)).toBe(pathsStateRoot(env));
}
});
});
// ============================================================
// gstack_hook_log_fire analytics sink follows the same state root (#1459)
// ============================================================
// The hook_fire record lands under ${GSTACK_HOME:-$HOME/.gstack}/analytics —
// the SAME two-step chain every other analytics writer and reader uses
// (gstack-skill-start, gstack-retro-metrics, gstack-analytics) — deliberately
// NOT the plugin-aware state root the freeze FILE uses, so the usage log stays
// one file. Logging is best-effort: an unwritable sink never changes the decision.
describe('gstack_hook_log_fire writes under the resolved state root', () => {
const BOUNDARY = '/Users/dev/project/src/';
const OUTSIDE = '/Users/dev/other-project/index.ts';
function lastRecord(file: string): any {
const lines = fs.readFileSync(file, 'utf-8').trim().split('\n');
return JSON.parse(lines[lines.length - 1]);
}
test('REGRESSION: a freeze deny under GSTACK_HOME appends hook_fire to $GSTACK_HOME/analytics, not $HOME/.gstack', () => {
withFreezeDir(BOUNDARY, (gstackHome) => {
withEmptyDir((fakeHome) => {
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
});
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
const rec = lastRecord(path.join(gstackHome, 'analytics', 'skill-usage.jsonl'));
expect(rec.event).toBe('hook_fire');
expect(rec.skill).toBe('freeze');
expect(rec.pattern).toBe('boundary_deny');
expect(typeof rec.ts).toBe('string');
expect(fs.existsSync(path.join(fakeHome, '.gstack'))).toBe(false);
});
});
});
test('plugin install: the freeze FILE is read from CLAUDE_PLUGIN_DATA but hook_fire still lands under $HOME/.gstack/analytics (one usage log)', () => {
withFreezeDir(BOUNDARY, (pluginData) => {
withEmptyDir((fakeHome) => {
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE),
freezeEnv(pluginData, { HOME: fakeHome, CLAUDE_PLUGIN_ROOT: '/Plugins/GSTACK' }));
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
const rec = lastRecord(path.join(fakeHome, '.gstack', 'analytics', 'skill-usage.jsonl'));
expect(rec.event).toBe('hook_fire');
expect(rec.skill).toBe('freeze');
expect(fs.existsSync(path.join(pluginData, 'analytics'))).toBe(false);
});
});
});
test('a GSTACK_HOME ending in a newline round-trips exactly (writer %q and reader sentinel agree)', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-nl-'));
const nlDir = path.join(base, 'root\n');
fs.mkdirSync(nlDir);
fs.writeFileSync(path.join(nlDir, 'freeze-dir.txt'), BOUNDARY);
try {
const { output } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
GSTACK_HOME: nlDir, HOME: base, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
});
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('an unexpected set -e death inside the hook (a tool on PATH failing) DENIES via the EXIT backstop instead of exiting with no JSON', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-backstop-'));
const fakeBin = path.join(base, 'bin');
fs.mkdirSync(fakeBin);
fs.writeFileSync(path.join(fakeBin, 'head'), '#!/bin/sh\nexit 1\n');
fs.chmodSync(path.join(fakeBin, 'head'), 0o755);
try {
withFreezeDir(BOUNDARY, (stateDir) => {
const { exitCode, output } = runHook(FREEZE_SCRIPT, freezeInput('/Users/dev/project/src/x.ts'),
freezeEnv(stateDir, { PATH: `${fakeBin}:${process.env.PATH ?? ''}` }));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('failed unexpectedly');
});
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('a hook helper from an older install that lacks gstack_hook_state_root DENIES (fail closed), never exit 127', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-oldhelper-'));
const freezeBin = path.join(base, 'freeze', 'bin');
const carefulBin = path.join(base, 'careful', 'bin');
fs.mkdirSync(freezeBin, { recursive: true });
fs.mkdirSync(carefulBin, { recursive: true });
fs.copyFileSync(FREEZE_SCRIPT, path.join(freezeBin, 'check-freeze.sh'));
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
const start = helper.indexOf('gstack_hook_state_root() {');
const end = helper.indexOf('\n}\n', start) + 3;
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
try {
withFreezeDir(BOUNDARY, (stateDir) => {
const { exitCode, output } = runHook(path.join(freezeBin, 'check-freeze.sh'), freezeInput('/Users/dev/project/src/x.ts'), freezeEnv(stateDir));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
// 'out of date' is the helper-without-function branch; the plain
// helpers-unavailable deny also says 'fail closed', so pin the specific one.
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('out of date');
});
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
test('an unwritable analytics sink never changes the decision: deny is still emitted as valid JSON', () => {
withFreezeDir(BOUNDARY, (gstackHome) => {
// `analytics` is a regular FILE, so mkdir -p and the >> append both fail.
fs.writeFileSync(path.join(gstackHome, 'analytics'), 'not a directory');
withEmptyDir((fakeHome) => {
const { exitCode, output, raw } = runHook(FREEZE_SCRIPT, freezeInput(OUTSIDE), {
GSTACK_HOME: gstackHome, HOME: fakeHome, CLAUDE_PLUGIN_DATA: '', CLAUDE_PLUGIN_ROOT: '',
});
expect(exitCode).toBe(0);
expect(() => JSON.parse(raw)).not.toThrow();
expect(output.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(fs.readFileSync(path.join(gstackHome, 'analytics'), 'utf-8')).toBe('not a directory');
});
});
});
});
+560
View File
@@ -672,3 +672,563 @@ describe('gstack-patch-names (#620/#578)', () => {
expect(content).toBe('# qa\nSome content.');
});
});
// ============================================================
// Ownership gate (#2119): relink runs on every ./setup and must never delete
// or link over a skill it does not own. Ownership = symlink into INSTALL_DIR
// or RENDER_DIR, a real dir whose SKILL.md is such a symlink, or the
// .gstack-owned marker setup writes for Windows copy installs.
// ============================================================
describe('gstack-relink ownership gate (#2119)', () => {
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
function relink(env: Record<string, string> = {}): string {
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
...env,
});
}
function setPrefix(v: 'true' | 'false') {
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
});
}
test('flat mode: a foreign real dir with a real SKILL.md is never linked over (Linux ln -snf would replace the file)', () => {
setupMockInstall(['qa', 'ship']);
// The foreign skill exists before gstack ever runs (gstack-config `set`
// auto-relinks, so fixtures go in first).
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
setPrefix('false');
const out = relink();
const md = path.join(skillsDir, 'qa', 'SKILL.md');
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
expect(out).toContain('Skipped 1 foreign entry');
// The other skill still links normally.
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
});
test('prefix flip: a foreign flat entry sharing a skill name survives the cleanup pass', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), FOREIGN);
setPrefix('true');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(true);
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
expect(out).toContain('skipped');
});
test('a foreign directory symlink sharing a skill name is left in place', () => {
setupMockInstall(['qa']);
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
fs.mkdirSync(elsewhere, { recursive: true });
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
fs.symlinkSync(elsewhere, path.join(skillsDir, 'qa'));
setPrefix('false');
const out = relink();
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(elsewhere);
expect(fs.readFileSync(path.join(elsewhere, 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
});
test('an entry whose SKILL.md links into RENDER_DIR is ours and is cleaned on a mode flip', () => {
setupMockInstall(['qa']);
setPrefix('false');
const renderDir = path.join(tmpDir, 'render', 'claude');
fs.mkdirSync(path.join(renderDir, 'qa'), { recursive: true });
fs.writeFileSync(path.join(renderDir, 'qa', 'SKILL.md'), '---\nname: gstack-qa\ndescription: rendered\n---\n');
// Stale prefixed entry from a prior prefix-mode run, pointing at the render.
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(renderDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
const out = relink({ GSTACK_USER_RENDER_DIR: renderDir });
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(renderDir, 'qa', 'SKILL.md'));
expect(out).not.toContain('skipped');
});
test('a real-file copy carrying the .gstack-owned marker (Windows install shape) is ours', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n');
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), '');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('a legacy RELATIVE symlink into the install (gstack/qa/SKILL.md) is ours and is cleaned', () => {
setupMockInstall(['qa']);
// Older setups wrote relative links; the skills dir sits beside the install
// dir named `gstack`, so `gstack/qa/SKILL.md` resolves into INSTALL_DIR.
const installAlias = path.join(skillsDir, 'gstack');
fs.symlinkSync(installDir, installAlias);
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('an entry linked against the REAL path of a symlinked install dir is ours', () => {
setupMockInstall(['qa']);
const realInstall = fs.realpathSync(installDir);
const linkInstall = path.join(tmpDir, 'install-link');
fs.symlinkSync(realInstall, linkInstall);
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(realInstall, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
// relink detects the install through the symlinked spelling.
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
});
const out = run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
GSTACK_INSTALL_DIR: linkInstall, GSTACK_SKILLS_DIR: skillsDir,
});
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('a real-file copy WITHOUT the marker is foreign and survives', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), FOREIGN);
const out = relink();
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
});
// Before the gate, a stray regular FILE named like a skill made `mkdir -p`
// fail under set -e and relink died mid-loop (exit 1, later skills never
// linked). A non-dir, non-symlink entry is simply foreign now.
test('a stray regular file with a skill name is foreign: relink completes, file untouched, other skills link', () => {
setupMockInstall(['qa', 'ship']);
fs.writeFileSync(path.join(skillsDir, 'qa'), 'stray notes\n');
setPrefix('false');
const out = relink();
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isFile()).toBe(true);
expect(fs.readFileSync(path.join(skillsDir, 'qa'), 'utf-8')).toBe('stray notes\n');
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(out).toContain('Relinked 1 skills as flat names');
expect(out).toContain('Skipped 1 foreign entry');
});
test('a real dir whose SKILL.md symlink points OUTSIDE install/render is foreign in both the link pass and the flip cleanup', () => {
setupMockInstall(['qa']);
const elsewhere = path.join(tmpDir, 'elsewhere', 'qa');
fs.mkdirSync(elsewhere, { recursive: true });
fs.writeFileSync(path.join(elsewhere, 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.symlinkSync(path.join(elsewhere, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
// Link pass (flat mode): the destination is not ours → never re-pointed.
setPrefix('false');
let out = relink();
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
expect(out).toContain('skipped');
// Cleanup pass (prefix flip): the stale flat name is not ours → kept.
setPrefix('true');
out = relink();
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(elsewhere, 'SKILL.md'));
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
expect(out).toContain('skipped');
});
test('a sibling directory that merely shares the install dir as a prefix (…/gstack-install-fork) is not ours', () => {
setupMockInstall(['qa']);
const fork = installDir + '-fork';
fs.mkdirSync(path.join(fork, 'qa'), { recursive: true });
fs.writeFileSync(path.join(fork, 'qa', 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.symlinkSync(path.join(fork, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'qa', 'SKILL.md'));
expect(fs.readFileSync(path.join(fork, 'qa', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(out).toContain('skipped');
});
test('several foreign entries: pluralized summary lists every name and the relinked count excludes them', () => {
setupMockInstall(['qa', 'ship', 'review']);
for (const name of ['qa', 'ship']) {
fs.mkdirSync(path.join(skillsDir, name));
fs.writeFileSync(path.join(skillsDir, name, 'SKILL.md'), FOREIGN);
}
setPrefix('false');
const out = relink();
expect(out).toContain('Relinked 1 skills as flat names');
expect(out).toContain('Skipped 2 foreign entries');
// Bare names, same wording as setup's own line, so setup can dedupe when it forwards relink's output.
expect(out).toContain('skipped qa: existing entry is not gstack-managed');
expect(out).toContain('skipped ship: existing entry is not gstack-managed');
expect(out).toContain('left untouched): qa ship');
expect(out).not.toContain('foreign entry ');
expect(fs.lstatSync(path.join(skillsDir, 'review', 'SKILL.md')).isSymbolicLink()).toBe(true);
});
test('an opposite-mode WHOLE-DIR symlink into the install (oldest install shape) is ours and is removed on a flip', () => {
setupMockInstall(['qa']);
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'gstack-qa'));
setPrefix('false');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(fs.lstatSync(path.join(skillsDir, 'gstack-qa'), { throwIfNoEntry: false })).toBeUndefined();
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(out).not.toContain('skipped');
});
});
describe('gstack-relink ownership gate parity with setup (#2119 review fixes)', () => {
const FOREIGN = '---\nname: qa\ndescription: my own qa skill\n---\n# not gstack';
function relink(env: Record<string, string> = {}): string {
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
...env,
});
}
function setPrefix(v: 'true' | 'false') {
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, {
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
});
}
test('a pre-marker legacy COPY carrying the generated header is ours (same rule as setup) and is cleaned on a flip', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# qa\n');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('a ONE-line AUTO-GENERATED substring is not provenance (another generator could emit it): entry survives', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-tool -->\n# theirs\n');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
expect(out).toContain('skipped');
});
test('an absolute link that walks through a gstack segment with `..` is canonicalized first, not fast-pathed as ours', () => {
setupMockInstall(['qa']);
const decoy = path.join(tmpDir, 'x', 'gstack');
const foreign = path.join(tmpDir, 'x', 'foreign');
fs.mkdirSync(decoy, { recursive: true });
fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(foreign, 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(decoy, '..', 'foreign', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(true);
expect(out).toContain('skipped');
});
test('a byte-identical copy of our source SKILL.md is ours even without marker or header', () => {
setupMockInstall(['qa']);
setPrefix('false');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('an entry linked into a SIBLING gstack checkout (path segment `gstack`) is ours, like setup and uninstall treat it', () => {
setupMockInstall(['qa']);
setPrefix('false');
const sibling = path.join(tmpDir, 'worktrees', 'gstack', 'qa');
fs.mkdirSync(sibling, { recursive: true });
fs.writeFileSync(path.join(sibling, 'SKILL.md'), '---\nname: qa\n---\n');
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(sibling, 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(out).not.toContain('skipped');
});
test('a fork under a `gstack-fork` directory is NOT ours (segment must be exactly gstack)', () => {
setupMockInstall(['qa']);
const fork = path.join(tmpDir, 'tools', 'gstack-fork', 'qa');
fs.mkdirSync(fork, { recursive: true });
fs.writeFileSync(path.join(fork, 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.symlinkSync(path.join(fork, 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(fork, 'SKILL.md'));
expect(out).toContain('skipped');
});
test('a DANGLING link from a moved checkout is healed, not reported foreign', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.symlinkSync(path.join(tmpDir, 'old-checkout', 'gstack', 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(out).not.toContain('skipped');
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
});
});
describe('gstack-relink root alias (_gstack-command) ownership gate', () => {
const ROOT_SKILL = '---\nname: gstack\ndescription: root\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# gstack root\n';
function relink(): string {
return run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, {
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
});
}
test('a user-owned _gstack-command skill is reported and left byte-identical', () => {
setupMockInstall(['qa']);
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
const mine = '---\nname: _gstack-command\ndescription: my own command runner\n---\n# mine\n';
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), mine);
const out = relink();
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toBe(mine);
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
expect(out).toContain('skipped');
expect(out).toContain('_gstack-command');
});
test('our own rewritten alias copy is refreshed and stamped with the ownership marker', () => {
setupMockInstall(['qa']);
fs.writeFileSync(path.join(installDir, 'SKILL.md'), ROOT_SKILL);
const first = relink();
expect(first).not.toContain('skipped');
const alias = path.join(skillsDir, '_gstack-command');
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
expect(fs.existsSync(path.join(alias, '.gstack-owned'))).toBe(true);
// Stale copy (older render) with the marker: refreshed, not reported.
fs.writeFileSync(path.join(alias, 'SKILL.md'), '---\nname: _gstack-command\n---\n# stale\n');
const second = relink();
expect(second).not.toContain('skipped');
expect(fs.readFileSync(path.join(alias, 'SKILL.md'), 'utf-8')).toContain('# gstack root');
});
});
describe('gstack-relink weak proof is file-scoped; differing files are moved aside (#2119 review)', () => {
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
const env = () => ({
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir, GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
});
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
const backups = () => {
const root = path.join(tmpDir, 'home', 'backups', 'skills');
if (!fs.existsSync(root)) return [] as string[];
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
};
test('flip cleanup on a banner-only copy removes SKILL.md and keeps the user\'s other files and the directory', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'my-templates'), { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), `---\nname: gstack-qa\n---\n${BANNER}\n# started from gstack\n`);
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
setPrefix('false');
const out = relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
expect(fs.readFileSync(path.join(skillsDir, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
expect(out).not.toContain('skipped');
});
test('a marker-proven directory (we created it) is still removed whole on a flip', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'gstack-qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), '# stale copy\n');
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'sections', 'a.md'), 'a\n');
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', '.gstack-owned'), installDir + '\n');
setPrefix('false');
relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
});
test('linking over a CUSTOMIZED banner copy moves it to the backup root first; the link then lands', () => {
setupMockInstall(['qa']);
const custom = `---\nname: qa\n---\n${BANNER}\n# my qa, started from gstack\n`;
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
setPrefix('false');
const out = relink();
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
const saved = backups();
expect(saved.length).toBe(1);
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
expect(out).not.toContain('skipped');
});
test('linking over a byte-identical copy makes no backup', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.copyFileSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
setPrefix('false');
relink();
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(backups()).toEqual([]);
});
});
describe('gstack-relink: checkout naming, legacy linked dirs, markers (#2119 review)', () => {
const env = () => ({
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'),
});
const relink = () => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env());
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
test('an entry linked into a checkout whose path has no `gstack` segment (git worktree add ../gs-feature) is ours when that tree carries setup + VERSION + bin/', () => {
setupMockInstall(['qa']);
const other = path.join(tmpDir, 'gs-feature');
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
fs.mkdirSync(path.join(other, 'bin'));
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(skillsDir, 'qa', 'SKILL.md'));
setPrefix('false');
const out = relink();
expect(out).not.toContain('skipped');
expect(fs.readlinkSync(path.join(skillsDir, 'qa', 'SKILL.md'))).toBe(path.join(installDir, 'qa', 'SKILL.md'));
// ...but a hand-written skill repo with VERSION + setup + bin/ (no gstack-relink) is not a gstack tree.
const plain = path.join(tmpDir, 'plain-tools');
fs.mkdirSync(path.join(plain, 'ship'), { recursive: true });
fs.mkdirSync(path.join(plain, 'bin'));
fs.writeFileSync(path.join(plain, 'VERSION'), '0.1\n');
fs.writeFileSync(path.join(plain, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(plain, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
fs.mkdirSync(path.join(installDir, 'ship'));
fs.writeFileSync(path.join(installDir, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
fs.mkdirSync(path.join(skillsDir, 'ship'));
fs.symlinkSync(path.join(plain, 'ship', 'SKILL.md'), path.join(skillsDir, 'ship', 'SKILL.md'));
const out2 = relink();
expect(out2).toContain('skipped ship');
});
test('a directory relink creates gets the marker on Linux; a pre-existing unclaimed directory does not', () => {
setupMockInstall(['qa', 'ship']);
fs.mkdirSync(path.join(skillsDir, 'ship'));
fs.writeFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'mine\n');
setPrefix('false');
relink();
expect(fs.existsSync(path.join(skillsDir, 'qa', '.gstack-owned'))).toBe(true);
expect(fs.lstatSync(path.join(skillsDir, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'ship', '.gstack-owned'))).toBe(false);
expect(fs.readFileSync(path.join(skillsDir, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
});
test('flip on a legacy linked dir (no marker): all-links dir is removed whole; a dir with a user file keeps the file and drops our links', () => {
setupMockInstall(['qa', 'ship']);
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
for (const name of ['gstack-qa', 'gstack-ship']) {
fs.mkdirSync(path.join(skillsDir, name));
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, name, 'SKILL.md'));
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, name, 'sections'));
}
fs.writeFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'keep\n');
// gstack-config `set` auto-relinks, so the flip cleanup runs there; capture both outputs.
const out = setPrefix('false') + relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship', 'sections'))).toBe(false);
expect(fs.readFileSync(path.join(skillsDir, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
expect(out).toContain('cleaned gstack-ship/SKILL.md');
expect(out).not.toContain('skipped');
});
});
describe('gstack-relink cycle-3 hardening: foreign dir links, failed backups, flip backups, mixed dirs (#2119 review)', () => {
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->';
const env = (extra: Record<string, string> = {}) => ({
GSTACK_INSTALL_DIR: installDir, GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: path.join(tmpDir, 'home'), GSTACK_USER_RENDER_DIR: path.join(tmpDir, 'no-render'), ...extra,
});
const relink = (extra: Record<string, string> = {}) => run(`${path.join(installDir, 'bin', 'gstack-relink')} 2>&1`, env(extra));
const setPrefix = (v: 'true' | 'false') => run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix ${v}`, env());
const backups = (home = path.join(tmpDir, 'home')) => {
const root = path.join(home, 'backups', 'skills');
if (!fs.existsSync(root)) return [] as string[];
return fs.readdirSync(root).flatMap((ts) => fs.readdirSync(path.join(root, ts)).map((n) => path.join(root, ts, n, 'SKILL.md')));
};
test('a foreign DIRECTORY symlink whose target has no SKILL.md is foreign, not unclaimed', () => {
setupMockInstall(['qa']);
const userdir = path.join(tmpDir, 'userdir');
fs.mkdirSync(userdir);
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
fs.symlinkSync(userdir, path.join(skillsDir, 'qa'));
setPrefix('false');
const out = relink();
expect(out).toContain('skipped qa');
expect(fs.readlinkSync(path.join(skillsDir, 'qa'))).toBe(userdir);
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
});
test('flip cleanup moves a CUSTOMIZED banner copy to the backup root instead of deleting it', () => {
setupMockInstall(['qa']);
const custom = `---\nname: gstack-qa\n---\n${BANNER}\n# customized\n`;
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.writeFileSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'), custom);
setPrefix('false');
relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
const saved = backups();
expect(saved.length).toBe(1);
expect(fs.readFileSync(saved[0], 'utf-8')).toBe(custom);
});
test('when the backup root cannot be created, the customized file stays and the entry is reported', () => {
setupMockInstall(['qa']);
const custom = `---\nname: qa\n---\n${BANNER}\n# customized\n`;
fs.mkdirSync(path.join(skillsDir, 'qa'));
fs.writeFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), custom);
fs.mkdirSync(path.join(tmpDir, 'home'));
fs.writeFileSync(path.join(tmpDir, 'home', 'backups'), 'not a dir');
setPrefix('false');
const out = relink();
expect(out).toContain('could not back up');
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(skillsDir, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
});
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays', () => {
setupMockInstall(['qa']);
fs.mkdirSync(path.join(installDir, 'qa', 'sections'));
fs.mkdirSync(path.join(skillsDir, 'gstack-qa'));
fs.symlinkSync(path.join(installDir, 'qa', 'SKILL.md'), path.join(skillsDir, 'gstack-qa', 'SKILL.md'));
fs.symlinkSync(path.join(installDir, 'qa', 'sections'), path.join(skillsDir, 'gstack-qa', 'sections'));
fs.writeFileSync(path.join(tmpDir, 'my-notes.md'), 'mine\n');
fs.symlinkSync(path.join(tmpDir, 'my-notes.md'), path.join(skillsDir, 'gstack-qa', 'notes.md'));
setPrefix('false');
relink();
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa', 'sections'))).toBe(false);
expect(fs.readlinkSync(path.join(skillsDir, 'gstack-qa', 'notes.md'))).toBe(path.join(tmpDir, 'my-notes.md'));
});
test('the root alias marker is written only for a directory relink creates', () => {
setupMockInstall(['qa']);
fs.writeFileSync(path.join(installDir, 'SKILL.md'), `---\nname: gstack\n---\n${BANNER}\n# root\n`);
fs.mkdirSync(path.join(skillsDir, '_gstack-command'));
fs.writeFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'mine\n');
setPrefix('false');
relink();
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'SKILL.md'), 'utf-8')).toContain('name: _gstack-command');
expect(fs.existsSync(path.join(skillsDir, '_gstack-command', '.gstack-owned'))).toBe(false);
expect(fs.readFileSync(path.join(skillsDir, '_gstack-command', 'notes.md'), 'utf-8')).toBe('mine\n');
});
});
+22 -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';
@@ -52,7 +52,18 @@ beforeAll(() => {
'SKILL_PREFIX=0',
'QUIET=1',
'_WINDOWS_COPY_NOTE_PRINTED=1',
'_FOREIGN_SKIPPED_ENTRIES=()',
`SOURCE_GSTACK_DIR="${ROOT}"`,
extractFn('_link_or_copy'),
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_is_ours'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_backup_skill_md'),
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-alias-test-backups"`,
extractFn('_write_owned_marker'),
extractFn('_print_windows_copy_note_once'),
extractFn('_link_skill_runtime_assets'),
extractFn('link_claude_skill_dirs'),
@@ -63,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}`);
}
@@ -144,12 +155,20 @@ describe('alias installs are rewritten copies (#2511, #2201)', () => {
const script = [
'set -e',
'IS_WINDOWS=0',
'_FOREIGN_SKIPPED_ENTRIES=()',
`SOURCE_GSTACK_DIR="${ROOT}"`,
extractFn('_link_or_copy'),
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_is_ours'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_write_owned_marker'),
extractFn('_install_alias_skill_md'),
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');
+135
View File
@@ -0,0 +1,135 @@
/**
* setup: the browser lines of the final summary. Aside (aside.com, macOS 15+)
* is the primary driver; the compiled browse binary is the fallback.
*
* Since the Chromium bootstrap became best-effort (#2802, _PW_FAIL_REASON),
* two places must consult that reason so they never promise a bundled browser
* that cannot launch, and never tell an Aside user their browser skills are
* gone when only the fallback is missing:
* - _browser_hint, the one-line "browser:" hint under every host's
* "gstack ready" block;
* - the Chromium bootstrap summary printed last.
* Behavior fixture: extract the code from setup and run it with the Aside
* probe stubbed and the reason set or empty.
*/
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 SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
return SETUP_SRC.slice(start, end + 2);
}
// The reason branch of the final summary, up to (not including) the
// foreign-entries report that follows it.
function summaryReasonBlock(): string {
const start = SETUP_SRC.indexOf('# ─── Chromium bootstrap summary');
const end = SETUP_SRC.indexOf('if [ ${#_FOREIGN_SKIPPED_ENTRIES[@]}', start);
if (start < 0 || end < 0) throw new Error('Could not locate the Chromium bootstrap summary block in setup');
return SETUP_SRC.slice(start, end);
}
// `command -v aside` is the only probe either site makes; shadow the builtin
// so the test never depends on whether the machine running it has Aside.
const COMMAND_SHADOW = 'command() { if [ "$1" = "-v" ] && [ "$2" = "aside" ]; then [ "$ASIDE_PRESENT" = "1" ]; else builtin command "$@"; fi; }';
function runBash(lines: string[]): string {
const r = spawnSync('bash', ['-c', lines.join('\n')], { encoding: 'utf-8', timeout: 30_000 });
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
return r.stdout;
}
function runHint(opts: { aside: boolean; reason: string }): string {
return runBash([
'set -e',
'log() { echo "$@"; }',
`ASIDE_PRESENT=${opts.aside ? 1 : 0}`,
COMMAND_SHADOW,
`_PW_FAIL_REASON=${JSON.stringify(opts.reason)}`,
extractFn('_browser_hint'),
'_browser_hint',
]);
}
function runSummary(opts: { aside: boolean; reason: string }): string {
return runBash([
'set -e',
'log() { echo "$@"; }',
`ASIDE_PRESENT=${opts.aside ? 1 : 0}`,
COMMAND_SHADOW,
'SOURCE_GSTACK_DIR=/nonexistent-gstack-dir', // no telemetry binary → the event is skipped
`_PW_FAIL_REASON=${JSON.stringify(opts.reason)}`,
summaryReasonBlock(),
'echo REACHED_END=1',
]);
}
describe('setup: _browser_hint', () => {
test('static pin: the hint reads _PW_FAIL_REASON', () => {
expect(extractFn('_browser_hint')).toContain('_PW_FAIL_REASON');
});
test('Aside present, bootstrap fine → Aside primary with the bundled fallback', () => {
const out = runHint({ aside: true, reason: '' });
expect(out).toContain('browser: Aside (primary) — gstack browser is the fallback');
});
test('Aside present, bootstrap failed → Aside primary, fallback named unavailable with the reason', () => {
const out = runHint({ aside: true, reason: 'chromium-install-timeout' });
expect(out).toContain('Aside (primary)');
expect(out).toContain('fallback unavailable');
expect(out).toContain('chromium-install-timeout');
expect(out).not.toContain('gstack browser is the fallback');
});
test('Aside absent, bootstrap fine → bundled browser is the fallback, Aside suggested', () => {
const out = runHint({ aside: false, reason: '' });
expect(out).toContain('browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)');
});
test('Aside absent, bootstrap failed → no browser promised; reason and both remedies named', () => {
const out = runHint({ aside: false, reason: 'chromium-install,post-install-launch' });
expect(out).toContain('browser: none available');
expect(out).toContain('chromium-install,post-install-launch');
expect(out).toContain('install Aside');
expect(out).toContain('re-run ./setup');
expect(out).not.toContain('gstack browser (fallback)');
});
});
describe('setup: Chromium bootstrap summary is Aside-aware', () => {
test('Aside present → skills keep running in Aside, only the fallback is missing, /pair-agent excepted', () => {
const out = runSummary({ aside: true, reason: 'chromium-install' });
expect(out).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install)');
expect(out).toContain('Aside is installed');
expect(out).toContain('only their bundled fallback is missing');
expect(out).toContain('/pair-agent needs the bundled browser itself');
expect(out).not.toContain('Skills that need it:');
expect(out).toContain('REACHED_END=1');
});
test('Aside absent → the pre-Aside wording: the skills need the bundled browser', () => {
const out = runSummary({ aside: false, reason: 'chromium-install' });
expect(out).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install)');
expect(out).toContain('Skills that need it:');
for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
expect(out).toContain(skill);
}
expect(out).not.toContain('Aside is installed');
expect(out).toContain('REACHED_END=1');
});
test('no failure → the reason branch prints nothing', () => {
const out = runSummary({ aside: true, reason: '' });
expect(out).not.toContain('Browser unavailable');
expect(out).toContain('REACHED_END=1');
});
});
+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 };
}
+20 -4
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';
@@ -66,10 +66,23 @@ beforeAll(() => {
extractFn('_link_or_copy'),
extractFn('_print_windows_copy_note_once'),
extractFn('_link_skill_runtime_assets'),
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_claude_entry_is_ours'),
extractFn('_write_owned_marker'),
extractFn('_backup_skill_md'),
extractFn('_cleanup_weak_dir'),
extractFn('_gstack_dir_only_links'),
extractFn('_cleanup_linked_dir'),
'_FOREIGN_SKIPPED_ENTRIES=()',
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
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}`);
}
@@ -138,11 +151,14 @@ describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', (
}
});
test('hidden files are not installed', () => {
test('hidden files are not installed (gstack\'s own provenance marker is the one allowed dotfile)', () => {
for (const skill of installedSkillDirs()) {
const hidden = fs
.readdirSync(path.join(installDir, skill))
.filter((e) => e.startsWith('.'));
.filter((e) => e.startsWith('.'))
// .gstack-owned is written by the linker for directories it creates (#2119),
// not copied from the skill source; every other dotfile must stay out.
.filter((e) => e !== '.gstack-owned');
expect(hidden).toEqual([]);
}
});
+54 -12
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';
@@ -24,7 +24,7 @@ function extractFn(name: string): string {
}
function cleanupBody(): string {
return extractFn('cleanup_old_claude_symlinks');
return [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="${os.tmpdir()}/cleanup-orphans-backups-${process.pid}"`, extractFn('cleanup_old_claude_symlinks')].join('\n');
}
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
@@ -68,13 +68,11 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
const script = [
'set -e',
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
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="${tmp}/backups"`,
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()
: [];
@@ -259,21 +257,65 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
}
});
test('Windows real-file leftover is removed when the payload still names it', () => {
// #2119: a bare name match used to delete a USER's own skill that happened to
// share a gstack skill name. Provenance must be proven: the .gstack-owned
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
// AUTO-GENERATED header (legacy copies made before the marker existed).
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ship\n';
const r = runCleanup({
isWindows: '1',
payload: true,
plant(skills, payload) {
const src = path.join(payload, 'qa');
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
plantUserSkill(skills, 'qa');
for (const name of ['qa', 'ship', 'review', 'browse']) {
fs.mkdirSync(path.join(payload, name));
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), name === 'ship' ? generated : `---\nname: ${name}\n---\n`);
}
// Byte-identical copy of the payload source → ours.
fs.mkdirSync(path.join(skills, 'qa'));
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
// Legacy copy carrying the generated header but drifted from source → ours.
fs.mkdirSync(path.join(skills, 'ship'));
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), generated.replace('# ship', '# ship (older render)'));
// Marker-carrying copy with arbitrary content → ours.
fs.mkdirSync(path.join(skills, 'browse'));
fs.writeFileSync(path.join(skills, 'browse', 'SKILL.md'), '---\nname: browse\n---\n# stale copy\n');
fs.writeFileSync(path.join(skills, 'browse', '.gstack-owned'), '');
// The user's OWN skill that shares a gstack name → foreign, must survive.
plantUserSkill(skills, 'review');
plantUserSkill(skills, 'my-own');
},
});
try {
expect(r.status).toBe(0);
expect(r.names).toEqual(['gstack', 'my-own']);
expect(r.names).toEqual(['gstack', 'my-own', 'review']);
expect(fs.readFileSync(path.join(r.tmp, 'skills', 'review', 'SKILL.md'), 'utf-8')).toContain('user-owned');
} finally {
fs.rmSync(r.tmp, { recursive: true, force: true });
}
});
// The Windows arm is the ONLY path that touches a real-file SKILL.md. On
// Unix a same-name real-file skill must survive even when the payload names
// it and even when its bytes are identical to the payload source.
test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => {
const r = runCleanup({
isWindows: '0',
payload: true,
plant(skills, payload) {
fs.mkdirSync(path.join(payload, 'qa'));
fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
fs.mkdirSync(path.join(skills, 'qa'));
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
fs.mkdirSync(path.join(skills, 'ship'));
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), '');
},
});
try {
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
expect(r.names).toEqual(['gstack', 'qa', 'ship']);
} finally {
fs.rmSync(r.tmp, { recursive: true, force: true });
}
+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),
+545
View File
@@ -0,0 +1,545 @@
/**
* setup never links over, copies over, or deletes a skill it does not own
* (#2119). The relink gate alone was not enough: link_claude_skill_dirs runs
* BEFORE relink on every ./setup, and on Linux `ln -snf` replaces a user's
* real SKILL.md with a symlink into gstack (on Windows: rm -rf + cp, then a
* marker that makes the user's dir "ours" on the next flip). The reverse
* mode-flip cleanup (cleanup_prefixed_claude_symlinks) kept a bare name-match
* deletion and a `*gstack*` substring match. Same anchor-sliced convention as
* test/setup-cleanup-orphans.test.ts.
*/
import { describe, test, expect } from 'bun:test';
import { runBashScript } from './helpers/bash-script';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
return SETUP_SRC.slice(start, end + 2);
}
const HELPERS = [
'_FOREIGN_SKIPPED_ENTRIES=()',
'_link_skill_runtime_assets() { :; }',
'_print_windows_copy_note_once() { :; }',
extractFn('_link_or_copy'),
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_claude_entry_is_ours'),
extractFn('_write_owned_marker'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_backup_skill_md'),
extractFn('_cleanup_weak_dir'),
extractFn('_gstack_dir_only_links'),
extractFn('_cleanup_linked_dir'),
'_BACKED_UP_SKILL_MDS=()',
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
].join('\n');
const FOREIGN = '---\nname: qa\ndescription: mine\n---\n# not gstack\n';
const GENERATED = (name: string) => `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`;
function mkTree(): { tmp: string; skills: string; payload: string } {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-own-'));
const skills = path.join(tmp, 'skills');
const payload = path.join(skills, 'gstack');
fs.mkdirSync(payload, { recursive: true });
return { tmp, skills, payload };
}
function bash(lines: string[], tmp: string) {
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}`);
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
describe.skipIf(process.platform === 'win32')('setup: link_claude_skill_dirs never links over a foreign skill (#2119)', () => {
for (const isWindows of ['0', '1'] as const) {
test(`IS_WINDOWS=${isWindows}: a foreign real SKILL.md survives byte-identical, gets no marker, is reported and counted`, () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), FOREIGN);
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, 'SKILL_PREFIX=0', HELPERS,
extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
const md = path.join(t.skills, 'qa', 'SKILL.md');
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(md, 'utf-8')).toBe(FOREIGN);
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
expect(r.stderr).toContain('skipped qa');
expect(r.stdout).toContain('FOREIGN=qa');
// The other skill still links normally.
expect(fs.existsSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(true);
expect(r.stdout).toContain('linked skills: ship');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
}
test('our own previous entry (symlink into the payload) is refreshed, not skipped', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=\n');
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('the Windows marker records the owning payload path and a marked copy is ours on the next run', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const first = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
expect(first.status).toBe(0);
const marker = path.join(t.skills, 'qa', '.gstack-owned');
expect(fs.readFileSync(marker, 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
// Second run over our own copy: refreshed, not reported.
const second = bash(['set -e', 'IS_WINDOWS=1', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(second.stdout).toContain('FOREIGN=\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === 'win32')('setup: _install_alias_skill_md never overwrites a foreign alias-named skill (#2119)', () => {
test('a user skill named connect-chrome survives; a generated alias copy is refreshed', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'open-gstack-browser'));
fs.writeFileSync(path.join(t.payload, 'open-gstack-browser', 'SKILL.md'), GENERATED('open-gstack-browser'));
fs.mkdirSync(path.join(t.skills, 'connect-chrome'));
fs.writeFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(t.skills, 'gstack-connect-chrome'));
fs.writeFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), GENERATED('gstack-connect-chrome').replace('# gstack-connect-chrome', '# old alias copy'));
const r = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/connect-chrome" connect-chrome`,
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/gstack-connect-chrome" gstack-connect-chrome`,
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(t.skills, 'connect-chrome', 'SKILL.md'), 'utf-8')).toBe(FOREIGN);
expect(r.stdout).toContain('FOREIGN=connect-chrome');
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).toContain('name: gstack-connect-chrome');
expect(fs.readFileSync(path.join(t.skills, 'gstack-connect-chrome', 'SKILL.md'), 'utf-8')).not.toContain('old alias copy');
// A pre-existing alias directory is never stamped (we did not create it); a created one is.
expect(fs.existsSync(path.join(t.skills, 'gstack-connect-chrome', '.gstack-owned'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'connect-chrome', '.gstack-owned'))).toBe(false);
const created = bash(['set -e', 'IS_WINDOWS=0', `SOURCE_GSTACK_DIR="${t.payload}"`, HELPERS, extractFn('_install_alias_skill_md'),
`_install_alias_skill_md "${t.payload}/open-gstack-browser/SKILL.md" "${t.skills}/fresh-alias" fresh-alias`], t.tmp);
expect(created.status).toBe(0);
expect(fs.readFileSync(path.join(t.skills, 'fresh-alias', '.gstack-owned'), 'utf-8').trim()).toBe(fs.realpathSync(t.payload));
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === 'win32')('setup: cleanup_prefixed_claude_symlinks proves provenance (#2119)', () => {
function runFlip(isWindows: '0' | '1', plant: (skills: string, payload: string) => void) {
const t = mkTree();
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
plant(t.skills, t.payload);
const r = bash(['set -e', `IS_WINDOWS=${isWindows}`, 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_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
const names = fs.readdirSync(t.skills).sort();
fs.rmSync(t.tmp, { recursive: true, force: true });
return { ...r, names };
}
test('Windows: a user-owned gstack-qa (no marker, not identical, no header) survives the prefix→flat flip', () => {
const r = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# user-owned\n');
});
expect(r.status).toBe(0);
expect(r.names).toEqual(['gstack', 'gstack-qa']);
expect(r.stdout).toBe('');
});
test('Windows: marker, byte-identical, and generated-header copies are reaped', () => {
const r = runFlip('1', (skills, payload) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'gstack-qa', 'SKILL.md'));
});
expect(r.names).toEqual(['gstack']);
const m = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n# stale\n');
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '');
});
expect(m.names).toEqual(['gstack']);
const h = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# older render'));
});
expect(h.names).toEqual(['gstack']);
});
test('Windows: weak proof (banner, no marker) removes only SKILL.md; the user\'s other files and the directory survive', () => {
const r = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa', 'my-templates'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa').replace('# gstack-qa', '# started from gstack, then customized'));
fs.writeFileSync(path.join(skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
});
expect(r.status).toBe(0);
expect(r.names).toEqual(['gstack', 'gstack-qa']);
expect(r.stdout).toContain('cleaned gstack-qa/SKILL.md');
// Strong proof (marker) still removes the directory we created.
const m = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '# stale\n');
fs.writeFileSync(path.join(skills, 'gstack-qa', 'sections', 'x.md'), 'x\n');
fs.writeFileSync(path.join(skills, 'gstack-qa', '.gstack-owned'), '/payload\n');
});
expect(m.names).toEqual(['gstack']);
});
test('Windows: a ONE-line AUTO-GENERATED substring from another generator is not provenance — the entry survives', () => {
const r = runFlip('1', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.writeFileSync(path.join(skills, 'gstack-qa', 'SKILL.md'), '---\nname: gstack-qa\n---\n<!-- AUTO-GENERATED from my-skill-builder -->\n# theirs\n');
});
expect(r.status).toBe(0);
expect(r.names).toEqual(['gstack', 'gstack-qa']);
});
test('a SKILL.md symlink whose target merely CONTAINS the substring gstack is not reaped; an anchored gstack/ segment is', () => {
const keep = runFlip('0', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
});
expect(keep.names).toEqual(['gstack', 'gstack-qa']);
const reap = runFlip('0', (skills) => {
fs.mkdirSync(path.join(skills, 'gstack-qa'));
fs.symlinkSync('../gstack/qa/SKILL.md', path.join(skills, 'gstack-qa', 'SKILL.md'));
});
expect(reap.names).toEqual(['gstack']);
expect(reap.stdout).toContain('cleaned up prefixed entries: gstack-qa');
});
});
describe.skipIf(process.platform === 'win32')('setup: weakly-proven files are moved aside, never overwritten (#2119 review)', () => {
test('Linux linker: a customized banner copy is moved to the backup root before the symlink lands; an identical copy is not', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
const custom = GENERATED('qa').replace('# qa', '# my qa, started from gstack');
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
fs.mkdirSync(path.join(t.skills, 'ship'));
fs.copyFileSync(path.join(t.payload, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`,
'echo "BACKED=${_BACKED_UP_SKILL_MDS[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
// Weakly-proven, pre-existing: no marker (it must never become deletable whole). Created dirs do get one.
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship'))).toBe(false);
expect(r.stdout).toContain('BACKED=qa\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('Windows flip: weak proof leaves the user\'s files in place', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'gstack-qa', 'my-templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), GENERATED('gstack-qa'));
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), '- mine\n');
const r = bash(['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_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'gstack-qa', 'my-templates', 'checklist.md'), 'utf-8')).toBe('- mine\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('_run_relink_quiet forwards relink\'s skipped and moved lines once, deduped against setup\'s own report', () => {
const t = mkTree();
try {
const fake = path.join(t.tmp, 'fake-relink');
fs.writeFileSync(fake, '#!/usr/bin/env bash\necho "linked 3 skills"\necho " skipped qa: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho " skipped ship: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2\necho "Moved 1 pre-existing SKILL.md file(s) to /x before linking gstack\'s: review"\n');
fs.chmodSync(fake, 0o755);
const r = bash(['set -e', '_FOREIGN_SKIPPED_ENTRIES=(qa)', `GSTACK_RELINK="${fake}"`, 'INSTALL_SKILLS_DIR=/dev/null', 'SOURCE_GSTACK_DIR=/dev/null',
extractFn('_run_relink_quiet'), '_run_relink_quiet', 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stderr.match(/skipped ship/g)?.length).toBe(1);
expect(r.stderr).not.toContain('skipped qa');
expect(r.stderr).toContain('Moved 1 pre-existing');
expect(r.stderr).not.toContain('linked 3 skills');
expect(r.stdout).toContain('FOREIGN=qa ship\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === 'win32')('setup: banner census, checkout naming, legacy linked dirs, markers (#2119 review)', () => {
test('every generated SKILL.md in this tree passes _gstack_generated_header (four carry the banner past line 40)', () => {
const files = fs.readdirSync(ROOT, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => path.join(ROOT, d.name, 'SKILL.md'))
.concat([path.join(ROOT, 'SKILL.md')])
.filter((f) => fs.existsSync(f) && fs.readFileSync(f, 'utf-8').includes('<!-- AUTO-GENERATED from'));
expect(files.length).toBeGreaterThan(30);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-banner-census-'));
try {
const r = bash(['set -e', extractFn('_gstack_generated_header'),
`for f in ${files.map((f) => `"${f}"`).join(' ')}; do _gstack_generated_header "$f" || echo "MISS $f"; done`], tmp);
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('a link into a checkout named without a gstack segment is ours when the tree carries setup + VERSION + bin/', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const other = path.join(t.tmp, 'gs-feature');
fs.mkdirSync(path.join(other, 'qa'), { recursive: true });
fs.mkdirSync(path.join(other, 'bin'));
fs.writeFileSync(path.join(other, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(other, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'bin', 'gstack-relink'), '#!/bin/bash\n');
fs.writeFileSync(path.join(other, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.symlinkSync(path.join(other, 'qa', 'SKILL.md'), path.join(t.skills, 'qa', 'SKILL.md'));
// A hand-written skill repo that happens to carry VERSION + setup + bin/ is NOT a gstack tree.
const mine = path.join(t.tmp, 'myskills');
fs.mkdirSync(path.join(mine, 'ship'), { recursive: true });
fs.mkdirSync(path.join(mine, 'bin'));
fs.writeFileSync(path.join(mine, 'VERSION'), '0.1\n');
fs.writeFileSync(path.join(mine, 'setup'), '#!/bin/bash\n');
fs.writeFileSync(path.join(mine, 'ship', 'SKILL.md'), FOREIGN);
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
fs.mkdirSync(path.join(t.skills, 'ship'));
fs.symlinkSync(path.join(mine, 'ship', 'SKILL.md'), path.join(t.skills, 'ship', 'SKILL.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=ship\n');
expect(fs.readlinkSync(path.join(t.skills, 'ship', 'SKILL.md'))).toBe(path.join(mine, 'ship', 'SKILL.md'));
expect(fs.readlinkSync(path.join(t.skills, 'qa', 'SKILL.md'))).toBe(path.join(t.payload, 'qa', 'SKILL.md'));
// Re-pointed, but the directory pre-existed: no marker (only directories we create get one).
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(false);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('Linux linker: a created directory gets the marker; a pre-existing unclaimed directory (user files, no SKILL.md) is linked into without one', () => {
const t = mkTree();
try {
for (const n of ['qa', 'ship']) { fs.mkdirSync(path.join(t.payload, n)); fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n)); }
fs.mkdirSync(path.join(t.skills, 'ship'));
fs.writeFileSync(path.join(t.skills, 'ship', 'notes.md'), 'mine\n');
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=\n');
expect(fs.existsSync(path.join(t.skills, 'qa', '.gstack-owned'))).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'ship', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(t.skills, 'ship', '.gstack-owned'))).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'ship', 'notes.md'), 'utf-8')).toBe('mine\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('flip on a legacy linked dir (no marker): all-links dir removed whole; a dir with a user file keeps the file, loses our links', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
for (const [name, src] of [['gstack-qa', 'qa'], ['gstack-ship', 'ship']] as const) {
fs.mkdirSync(path.join(t.skills, name));
fs.symlinkSync(path.join(t.payload, src, 'SKILL.md'), path.join(t.skills, name, 'SKILL.md'));
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, name, 'sections'));
}
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'keep\n');
const r = bash(['set -e', 'IS_WINDOWS=0', 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_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'gstack-ship', 'sections'))).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'gstack-ship', 'my-notes.md'), 'utf-8')).toBe('keep\n');
expect(r.stdout).toContain('cleaned gstack-ship/SKILL.md');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === 'win32')('setup: cycle-3 hardening — assets, foreign dir links, failed backups, flip backups (#2119 review)', () => {
const REAL_ASSETS = extractFn('_link_skill_runtime_assets');
test('an unclaimed or weakly-owned directory keeps the user\'s same-named real assets; a created directory gets ours', () => {
const t = mkTree();
try {
for (const n of ['qa', 'ship', 'review']) {
fs.mkdirSync(path.join(t.payload, n, 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.payload, n, 'SKILL.md'), GENERATED(n));
fs.writeFileSync(path.join(t.payload, n, 'templates', 'ours.md'), 'ours\n');
fs.writeFileSync(path.join(t.payload, n, 'checklist.md'), 'ours\n');
}
// qa: unclaimed (no SKILL.md) with the user's templates/ and checklist.md
fs.mkdirSync(path.join(t.skills, 'qa', 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'mine\n');
fs.writeFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'my checklist\n');
// ship: weakly owned (customized banner copy) with the user's templates/
fs.mkdirSync(path.join(t.skills, 'ship', 'templates'), { recursive: true });
fs.writeFileSync(path.join(t.skills, 'ship', 'SKILL.md'), GENERATED('ship').replace('# ship', '# customized'));
fs.writeFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'mine\n');
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, REAL_ASSETS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(t.skills, 'qa', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
expect(fs.readFileSync(path.join(t.skills, 'qa', 'checklist.md'), 'utf-8')).toBe('my checklist\n');
expect(fs.lstatSync(path.join(t.skills, 'qa', 'checklist.md')).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'ship', 'templates', 'mine.md'), 'utf-8')).toBe('mine\n');
expect(r.stderr).toContain('kept qa/templates');
expect(r.stderr).toContain('kept qa/checklist.md');
expect(r.stderr).toContain('kept ship/templates');
// review: created by us → assets linked
expect(fs.lstatSync(path.join(t.skills, 'review', 'templates')).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'review', 'checklist.md')).isSymbolicLink()).toBe(true);
// ship's customized SKILL.md was backed up, its assets kept, its checklist (absent before) linked
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'ship', 'SKILL.md'))).toBe(true);
expect(fs.lstatSync(path.join(t.skills, 'ship', 'checklist.md')).isSymbolicLink()).toBe(true);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('a foreign DIRECTORY symlink (target has no SKILL.md) is foreign, not unclaimed: the user\'s link survives', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const userdir = path.join(t.tmp, 'userdir');
fs.mkdirSync(userdir);
fs.writeFileSync(path.join(userdir, 'notes.md'), 'mine\n');
fs.symlinkSync(userdir, path.join(t.skills, 'qa'));
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(r.stdout).toContain('FOREIGN=qa\n');
expect(fs.lstatSync(path.join(t.skills, 'qa')).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(path.join(t.skills, 'qa'))).toBe(userdir);
expect(fs.existsSync(path.join(userdir, 'SKILL.md'))).toBe(false);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('when the backup cannot be written, the customized file is left untouched and the entry is reported, never overwritten', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
const custom = GENERATED('qa').replace('# qa', '# customized');
fs.mkdirSync(path.join(t.skills, 'qa'));
fs.writeFileSync(path.join(t.skills, 'qa', 'SKILL.md'), custom);
fs.writeFileSync(path.join(t.tmp, 'not-a-dir'), 'x');
const r = bash(['set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', HELPERS, `_SKILL_BACKUP_ROOT="${t.tmp}/not-a-dir/backups"`, extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${t.payload}" "${t.skills}"`, 'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"'], t.tmp);
expect(r.status).toBe(0);
expect(fs.lstatSync(path.join(t.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(path.join(t.skills, 'qa', 'SKILL.md'), 'utf-8')).toBe(custom);
expect(r.stderr).toContain('could not back up');
expect(r.stdout).toContain('FOREIGN=qa\n');
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('Windows flip: a customized banner copy is moved to the backup root, not deleted; an alias-shaped copy (only name: differs) is just removed', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa'));
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.payload, 'ship'));
fs.writeFileSync(path.join(t.payload, 'ship', 'SKILL.md'), GENERATED('ship'));
const custom = GENERATED('gstack-qa').replace('# gstack-qa', '# customized on windows');
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
fs.writeFileSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'), custom);
fs.mkdirSync(path.join(t.skills, 'gstack-ship'));
fs.writeFileSync(path.join(t.skills, 'gstack-ship', 'SKILL.md'), GENERATED('ship').replace('name: ship', 'name: gstack-ship'));
const r = bash(['set -e', 'IS_WINDOWS=1', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa'))).toBe(false);
expect(fs.readFileSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-qa', 'SKILL.md'), 'utf-8')).toBe(custom);
expect(fs.existsSync(path.join(t.skills, 'gstack-ship'))).toBe(false);
expect(fs.existsSync(path.join(t.tmp, '.gstack', 'backups', 'skills', 'test', 'gstack-ship'))).toBe(false);
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
test('a legacy linked dir holding the user\'s OWN symlink is mixed: our links go, theirs stays, the dir stays', () => {
const t = mkTree();
try {
fs.mkdirSync(path.join(t.payload, 'qa', 'sections'), { recursive: true });
fs.writeFileSync(path.join(t.payload, 'qa', 'SKILL.md'), GENERATED('qa'));
fs.mkdirSync(path.join(t.skills, 'gstack-qa'));
fs.symlinkSync(path.join(t.payload, 'qa', 'SKILL.md'), path.join(t.skills, 'gstack-qa', 'SKILL.md'));
fs.symlinkSync(path.join(t.payload, 'qa', 'sections'), path.join(t.skills, 'gstack-qa', 'sections'));
fs.writeFileSync(path.join(t.tmp, 'my-notes.md'), 'mine\n');
fs.symlinkSync(path.join(t.tmp, 'my-notes.md'), path.join(t.skills, 'gstack-qa', 'notes.md'));
const r = bash(['set -e', 'IS_WINDOWS=0', HELPERS, extractFn('cleanup_prefixed_claude_symlinks'),
`cleanup_prefixed_claude_symlinks "${t.payload}" "${t.skills}"`], t.tmp);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(path.join(t.skills, 'gstack-qa', 'sections'))).toBe(false);
expect(fs.readlinkSync(path.join(t.skills, 'gstack-qa', 'notes.md'))).toBe(path.join(t.tmp, 'my-notes.md'));
} finally {
fs.rmSync(t.tmp, { recursive: true, force: true });
}
});
});
+649
View File
@@ -0,0 +1,649 @@
/**
* setup: Chromium bootstrap is best-effort and bounded (#1900, #1901, #1902,
* #913, #2233).
*
* Before: `set -e` plus a bare `bunx playwright install chromium` (and an
* explicit `exit 1` after the post-install probe) sat in section "# 2", ahead
* of "# 4. Install for Claude". An offline, proxied, or AppArmor-restricted
* box ended with ZERO skills registered, and a wedged download hung setup
* forever. Now every failure records a reason code in _PW_FAIL_REASON, the
* install is deadline-bounded, lock contention is a reason (not a fatal), and
* skill registration always runs.
*
* Two layers, following test/setup-emoji-font.test.ts's convention:
* 1. static invariants over the anchor-sliced block (line-number agnostic);
* 2. an integration harness that executes the REAL block with stubbed
* probe/installer so the exit path and reason codes are exercised.
*/
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';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const BLOCK_START = "# 2. Ensure Playwright's Chromium is available";
const BLOCK_END = '# 2b. Ensure a color-emoji font';
function slice(startAnchor: string, endAnchor: string): string {
const start = SETUP_SRC.indexOf(startAnchor);
const end = SETUP_SRC.indexOf(endAnchor, start);
if (start < 0 || end < 0) throw new Error(`anchor not found: ${startAnchor} .. ${endAnchor}`);
return SETUP_SRC.slice(start, end);
}
function extractFn(name: string): string {
const start = SETUP_SRC.indexOf(`${name}() {`);
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`function not found: ${name}`);
return SETUP_SRC.slice(start, end + 2);
}
const block = slice(BLOCK_START, BLOCK_END);
const codeLines = block.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
describe('setup: Chromium bootstrap static invariants', () => {
test('no exit inside the bootstrap block (skills must always register)', () => {
// The only `exit` allowed is inside the INT/TERM trap string (Ctrl-C must
// still terminate setup after killing the installer); a bare statement is not.
const statements = codeLines.split('\n').filter((l) => !/^\s*trap /.test(l)).join('\n');
expect(statements).not.toMatch(/\bexit 1\b/);
expect(statements).not.toMatch(/\bexit\b/);
expect(codeLines).toMatch(/trap '_kill_tree "\$_PW_PID".*exit 130' INT TERM/);
expect(codeLines).toContain('trap - INT TERM');
});
test('every failure arm records a reason code', () => {
for (const code of [
'skipped', 'chromium-install', 'chromium-install-timeout', 'chromium-install-locked',
'windows-no-node', 'windows-node-modules', 'post-install-launch',
]) {
expect(codeLines).toContain(`_pw_fail ${code} `);
}
});
test('the download is deadline-bounded through the shared helper and env knob', () => {
expect(codeLines).toContain('_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT"');
expect(codeLines).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT');
// Non-numeric and 0 fall back to the default instead of breaking arithmetic
// or killing the install on the first poll.
expect(codeLines).toMatch(/case "\$_PW_INSTALL_TIMEOUT" in ''\|\*\[!0-9\]\*\)/);
expect(codeLines).toContain('_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))');
expect(codeLines).toContain('[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600');
expect(codeLines).toContain('[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600');
});
test('lock contention is a reason code, not a fatal', () => {
expect(codeLines).toContain('_pw_fail chromium-install-locked');
expect(block).toContain('GSTACK_SKIP_PLAYWRIGHT');
});
test('the lock EXIT trap still chains cleanup_copied_bun and is restored', () => {
expect(codeLines).toContain("trap 'rm -rf \"$_PW_LOCK\" 2>/dev/null || true; cleanup_copied_bun' EXIT");
expect(codeLines).toContain('trap cleanup_copied_bun EXIT');
});
test('the daemon font refresh is skipped when Chromium is unavailable', () => {
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
expect(emoji).toContain('elif [ -z "$_PW_FAIL_REASON" ]; then');
expect(emoji).toContain('refresh_browse_daemon_for_fonts');
});
test('the final summary names the affected skills and the reason', () => {
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('Chromium bootstrap summary'));
expect(tail).toContain('Browser unavailable');
for (const skill of ['/qa', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
expect(tail).toContain(skill);
}
expect(tail).toContain('$_PW_FAIL_REASON');
expect(tail).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
expect(tail).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
});
});
/**
* Integration harness: the real block + the real deadline helpers, with the
* probe and installer stubbed. `bunx` is a shell function, so the block's
* subshell inherits the stub. Emits REASON=... and REACHED_END=1 so the test
* can prove setup continued past the block.
*/
function runBlock(opts: {
probe: 'ok' | 'fail' | 'fail-then-ok'; // fail-then-ok = fresh install: probe fails, install runs, probe passes
bunx: string; // body of the bunx stub
env?: Record<string, string>;
preLockPid?: string; // pre-create the install lock held by this pid
preLockNoPidAgeMin?: number; // pre-create a lock dir with NO pid file, this many minutes old
preLockAgeMin?: number; // with preLockPid: age the lock dir this many minutes
markKill?: boolean; // record _kill_tree invocations to $MARK
isWindows?: '0' | '1';
prelude?: string; // extra shell lines (node/npm stubs) injected before the block
platformOverride?: string; // value for _PLAYWRIGHT_PLATFORM_OVERRIDE (Ubuntu 26.04 path)
}): { stdout: string; stderr: string; status: number; elapsedMs: number; tmp: string } {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-block-'));
const mark = path.join(tmp, 'mark');
const killTree = opts.markKill
? extractFn('_kill_tree').replace('_kill_tree() {', '_kill_tree_orig() {') +
`\n_kill_tree() { echo killed >> "${mark}"; _kill_tree_orig "$1"; }\n`
: extractFn('_kill_tree');
if (opts.preLockPid) {
const lock = path.join(tmp, 'gstack-playwright-install.lock');
fs.mkdirSync(lock);
fs.writeFileSync(path.join(lock, 'pid'), opts.preLockPid);
if (opts.preLockAgeMin !== undefined) {
const t = new Date(Date.now() - opts.preLockAgeMin * 60_000);
fs.utimesSync(lock, t, t);
}
}
if (opts.preLockNoPidAgeMin !== undefined) {
const lock = path.join(tmp, 'gstack-playwright-install.lock');
fs.mkdirSync(lock);
const t = new Date(Date.now() - opts.preLockNoPidAgeMin * 60_000);
fs.utimesSync(lock, t, t);
}
const probeFn = opts.probe === 'fail-then-ok'
? 'ensure_playwright_browser() { if [ -f "$MARK.probed" ]; then return 0; fi; : > "$MARK.probed"; return 1; }'
: `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`;
const script = [
'set -e',
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
`SOURCE_GSTACK_DIR="${tmp}"`,
`TMPDIR="${tmp}"`,
`MARK="${mark}"`,
`_PLAYWRIGHT_PLATFORM_OVERRIDE="${opts.platformOverride ?? ''}"`,
// EXIT-trap witness: the block chains its lock trap onto cleanup_copied_bun
// and must leave cleanup_copied_bun installed when it is done.
'cleanup_copied_bun() { echo cleanup >> "$MARK.exit"; }',
'trap cleanup_copied_bun EXIT',
'_clear_playwright_quarantine() { :; }',
probeFn,
opts.prelude ?? '',
`bunx() { echo "bunx-called override=${'$'}{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-unset}" >> "$MARK"; ${opts.bunx}; }`,
killTree,
extractFn('_wait_with_deadline'),
// The block opens with the /etc/os-release probe that RESETS the override
// variable; a test that injects an override must start after that probe.
opts.platformOverride !== undefined ? slice('# Chromium is BEST-EFFORT', BLOCK_END) : block,
'echo "REASON=$_PW_FAIL_REASON"',
'echo "REACHED_END=1"',
].join('\n');
const scriptPath = path.join(tmp, 'block.sh');
fs.writeFileSync(scriptPath, script);
const t0 = Date.now();
const r = spawnSync('bash', [scriptPath], {
encoding: 'utf-8',
timeout: 60_000,
env: { PATH: process.env.PATH ?? '', HOME: tmp, ...(opts.env ?? {}) },
});
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, elapsedMs: Date.now() - t0, tmp };
}
describe('setup: Chromium bootstrap block executes best-effort', () => {
test('probe ok: no reason recorded, no install attempted', () => {
const r = runBlock({ probe: 'ok', bunx: 'exit 0' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=\n');
expect(r.stdout).toContain('REACHED_END=1');
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
});
test('install exits non-zero: reason chromium-install, setup continues (was: exit 1 before any skill registered)', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install\n');
expect(r.stdout).toContain('REACHED_END=1');
expect(r.stderr).toContain('chromium-install');
expect(r.stderr).toContain('exited 7');
});
test('install hangs: killed at the deadline with reason chromium-install-timeout, tree kill recorded', () => {
const r = runBlock({
probe: 'fail', bunx: 'sleep 30', markKill: true,
env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '1' },
});
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install-timeout\n');
expect(r.stdout).toContain('REACHED_END=1');
expect(r.elapsedMs).toBeLessThan(20_000);
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
}, 15_000);
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install\n');
});
test('lock held by a live process: reason chromium-install-locked, setup continues (was: exit 1)', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid) });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install-locked\n');
expect(r.stdout).toContain('REACHED_END=1');
// The installer must not have run under a foreign lock.
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
// And the foreign lock is left for its owner.
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(true);
});
test('stale lock (dead pid) is reclaimed and the install proceeds', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: '4194305' /* above Linux's largest pid_max: never a live pid */ });
expect(r.status).toBe(0);
expect(r.stderr).toContain('reclaiming stale Chromium-install lock');
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
});
test('a lock whose pid file is garbage (-1, abc, 0) is stale, not "locked": kill -0 -1 would signal everything and "succeed"', () => {
for (const pid of ['-1', 'abc', '0']) {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: pid });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
expect(r.stdout).not.toContain('chromium-install-locked');
}
}, 15_000);
test('a lock held by a LIVE pid but older than the install bound is reclaimed (holder past its deadline, or a recycled pid)', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: String(process.pid), preLockAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
expect(r.stdout).not.toContain('chromium-install-locked');
expect(r.stderr).toContain('past the install bound');
}, 15_000);
test('a lock dir with NO pid file is reclaimed once older than the install bound, and honored while fresh', () => {
const old = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
expect(old.status).toBe(0);
expect(fs.readFileSync(path.join(old.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
expect(old.stdout).not.toContain('chromium-install-locked');
const fresh = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 0, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '600' } });
expect(fresh.status).toBe(0);
expect(fresh.stdout).toContain('REASON=chromium-install-locked\n');
}, 15_000);
test('_kill_tree without pgrep on PATH still kills the grandchild (walks /proc)', () => {
if (!fs.existsSync('/proc')) return;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-'));
try {
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
for (const name of ['bash', 'sleep', 'awk']) {
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
if (real) fs.symlinkSync(real, path.join(bin, name));
}
const script = [
`export PATH="${bin}"`,
'hash -r',
'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 0; }',
extractFn('_kill_tree'),
// two commands so bash forks a real subshell instead of exec-ing sleep directly
'( sleep 30; true ) & pid=$!',
'sleep 0.3',
// find the sleep grandchild via /proc, the same way the fallback does
'child=$(awk -v p="$pid" \'{ s=$0; sub(/^[^)]*\\) /, "", s); split(s, f, " "); if (f[2]==p) { print $1; exit } }\' /proc/[0-9]*/stat 2>/dev/null)',
'[ -n "$child" ] || { echo "NO_CHILD"; exit 0; }',
'_kill_tree "$pid"',
'sleep 0.3',
'if kill -0 "$child" 2>/dev/null; then echo "CHILD_ALIVE"; else echo "CHILD_DEAD"; fi',
].join('\n');
const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 });
expect(r.stdout).toContain('CHILD_DEAD');
expect(r.stdout).not.toContain('PGREP_PRESENT');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 30_000);
test('install succeeds but the post-install probe fails: reason post-install-launch with the userns hint', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=post-install-launch\n');
expect(r.stderr).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
});
test('GSTACK_SKIP_PLAYWRIGHT=1: reason skipped, installer never invoked (#913)', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', env: { GSTACK_SKIP_PLAYWRIGHT: '1' } });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=skipped\n');
expect(fs.existsSync(path.join(r.tmp, 'mark'))).toBe(false);
});
});
/** A PATH that carries the tools the block needs but NO node — `command -v`
* also finds shell functions, so hiding node from PATH is the only faithful
* way to stand in for a Windows box without Node.js. */
function pathWithoutNode(): { bin: string; cleanup: () => void } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nonode-'));
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin);
for (const name of ['bash', 'mkdir', 'rm', 'sleep', 'cat', 'pgrep', 'grep', 'cut', 'tr', 'dirname', 'basename']) {
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
if (real) fs.symlinkSync(real, path.join(bin, name));
}
return { bin, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
}
describe('setup: Chromium bootstrap block — fresh install, lock hygiene, override, Windows arms', () => {
test('fresh install happy path: probe fails, install succeeds, re-probe passes → no reason, lock released, EXIT trap restored', () => {
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=\n');
expect(r.stdout).toContain('REACHED_END=1');
// Installer ran exactly once (no override on a non-26.04 path).
const mark = fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8');
expect(mark.split('\n').filter((l) => l.startsWith('bunx-called')).length).toBe(1);
expect(mark).toContain('override=unset');
// The mkdir-mutex is released for the next setup...
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
// ...and the chained trap was restored, so cleanup_copied_bun still fires at exit.
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
});
test('failed install still releases the lock and keeps cleanup_copied_bun on the EXIT trap', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 7' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install\n');
expect(fs.existsSync(path.join(r.tmp, 'gstack-playwright-install.lock'))).toBe(false);
expect(fs.readFileSync(path.join(r.tmp, 'mark.exit'), 'utf-8')).toContain('cleanup');
});
test('Ubuntu 26.04 override reaches the installer as PLAYWRIGHT_HOST_PLATFORM_OVERRIDE (#2101)', () => {
const r = runBlock({ probe: 'fail-then-ok', bunx: 'exit 0', platformOverride: 'ubuntu24.04-x64' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=\n');
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('override=ubuntu24.04-x64');
});
test('Windows without Node.js: reason windows-no-node, setup continues (was: exit 1), post-install probe skipped', () => {
const { bin, cleanup } = pathWithoutNode();
try {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', env: { PATH: bin } });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=windows-no-node\n');
expect(r.stdout).toContain('REACHED_END=1');
expect(r.stderr).toContain('nodejs.org');
// The install itself ran; only the Node verification failed.
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
} finally {
cleanup();
}
});
test('Windows with Node.js but npm cannot install playwright/@ngrok: reason windows-node-modules, setup continues', () => {
const r = runBlock({
probe: 'fail', bunx: 'exit 0', isWindows: '1',
prelude: 'node() { return 1; }\nnpm() { echo "npm $*" >> "$MARK"; return 1; }',
});
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=windows-node-modules\n');
expect(r.stdout).toContain('REACHED_END=1');
expect(r.stdout).toContain('Windows detected');
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('npm install --no-save playwright');
});
test('Windows with Node.js loading Playwright but the launch probe failing: Windows-specific post-install-launch hint', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', isWindows: '1', prelude: 'node() { return 0; }' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=post-install-launch\n');
expect(r.stderr).toContain('via Node.js');
expect(r.stderr).toContain('oven-sh/bun#4253');
// The Linux userns hint belongs to the other arm.
expect(r.stderr).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
});
});
/** The 2b emoji-font step: the daemon font refresh must only run when the
* browser actually works — otherwise setup prints a second failure line. */
function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr: string; status: number } {
const emoji = slice(BLOCK_END, '# 3. Ensure ~/.gstack global state directory exists');
const script = [
'set -e',
`_PW_FAIL_REASON="${reason}"`,
`ensure_emoji_font() { return ${fontOk ? 0 : 1}; }`,
'refresh_browse_daemon_for_fonts() { echo REFRESHED; }',
emoji,
'echo "REACHED_END=1"',
].join('\n');
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 };
}
describe('setup: emoji-font daemon refresh is gated on Chromium availability', () => {
test('font installed + Chromium usable → daemon refreshed; font installed + Chromium unavailable → no refresh; font missing → note, no refresh', () => {
const usable = runEmojiStep('', true);
expect(usable.status).toBe(0);
expect(usable.stdout).toContain('REFRESHED');
expect(usable.stdout).toContain('REACHED_END=1');
const unavailable = runEmojiStep('chromium-install', true);
expect(unavailable.status).toBe(0);
expect(unavailable.stdout).not.toContain('REFRESHED');
expect(unavailable.stdout).toContain('REACHED_END=1');
const noFont = runEmojiStep('', false);
expect(noFont.status).toBe(0);
expect(noFont.stdout).not.toContain('REFRESHED');
expect(noFont.stderr).toContain('could not auto-install a color-emoji font');
expect(noFont.stdout).toContain('REACHED_END=1');
});
});
/** The final summary block, executed with a recording telemetry stub. */
function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing', prelude: string[] = []): { stdout: string; stderr: string; status: number; argv: string } {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pw-summary-'));
try {
const argvFile = path.join(tmp, 'telemetry.argv');
if (telemetry !== 'missing') {
fs.mkdirSync(path.join(tmp, 'bin'));
const stub = path.join(tmp, 'bin', 'gstack-telemetry-log');
fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$@" >> "${argvFile}"\nexit ${telemetry === 'ok' ? 0 : 1}\n`);
fs.chmodSync(stub, 0o755);
}
const tail = SETUP_SRC.slice(SETUP_SRC.indexOf('# ─── Chromium bootstrap summary'));
const script = [
'set -e',
'QUIET=0',
'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }',
`SOURCE_GSTACK_DIR="${tmp}"`,
`_PW_FAIL_REASON="${reason}"`,
...prelude,
tail,
'echo "REACHED_END=1"',
].join('\n');
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 };
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
describe('setup: Chromium bootstrap summary block executes', () => {
test('names the reason and the affected skills; hints are reason-specific; telemetry gets the reason code only', () => {
const timeout = runSummary('chromium-install-timeout', 'ok');
expect(timeout.status).toBe(0);
expect(timeout.stdout).toContain('Browser unavailable: Chromium bootstrap did not complete (chromium-install-timeout)');
for (const skill of ['/qa', '/qa-only', '/design-review', '/browse', 'make-pdf', '/pair-agent']) {
expect(timeout.stdout).toContain(skill);
}
expect(timeout.stdout).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
expect(timeout.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
// Reason code only, never a path or command line; --no-sweep keeps the
// one-shot event from finalizing other sessions' pending markers.
expect(timeout.argv.trim()).toBe('--event-type onboarding --skill _setup_playwright --outcome chromium-install-timeout --no-sweep');
const launch = runSummary('post-install-launch', 'ok');
expect(launch.stdout).toContain('GSTACK_CHROMIUM_NO_SANDBOX=1');
expect(launch.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
expect(launch.argv).toContain('--outcome post-install-launch');
const offline = runSummary('chromium-install', 'ok');
expect(offline.stdout).toContain('Browser unavailable');
expect(offline.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
expect(offline.stdout).not.toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
});
test('silent when Chromium is fine; a missing or failing telemetry binary never breaks the tail of setup', () => {
const fine = runSummary('', 'ok');
expect(fine.status).toBe(0);
expect(fine.stdout).toBe('REACHED_END=1\n');
expect(fine.argv).toBe('');
const missing = runSummary('chromium-install', 'missing');
expect(missing.status).toBe(0);
expect(missing.stdout).toContain('Browser unavailable');
expect(missing.stdout).toContain('REACHED_END=1');
const failing = runSummary('chromium-install', 'fail');
expect(failing.status).toBe(0);
expect(failing.stdout).toContain('REACHED_END=1');
expect(failing.argv).toContain('--outcome chromium-install');
});
test('the summary names foreign entries left untouched and customized SKILL.md files moved to the backup root', () => {
const r = runSummary('', 'missing', ['_FOREIGN_SKIPPED_ENTRIES=(qa)', '_BACKED_UP_SKILL_MDS=(ship review)', '_SKILL_BACKUP_ROOT="/tmp/gstack-bk/20260904"']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Not registered (a skill you own already uses the name; left untouched): qa');
expect(r.stdout).toContain("Moved 2 customized SKILL.md file(s) to /tmp/gstack-bk/20260904 before installing gstack's: ship review");
expect(r.stdout).not.toContain('Browser unavailable');
expect(r.stdout).toContain('REACHED_END=1');
// Nothing to report → neither line.
const quiet = runSummary('', 'missing');
expect(quiet.stdout).not.toContain('Not registered');
expect(quiet.stdout).not.toContain('Moved ');
});
test('an explicit opt-out (skipped) is reported as a choice, not a failure, and sends no telemetry', () => {
const skipped = runSummary('skipped', 'ok');
expect(skipped.status).toBe(0);
expect(skipped.stdout).toContain('Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)');
expect(skipped.stdout).not.toContain('Browser unavailable');
expect(skipped.stdout).not.toContain('Fix the cause');
expect(skipped.argv).toBe('');
});
test('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=0, 000, or a value past nine digits means the default, not kill-on-first-poll or unbounded', () => {
for (const v of ['0', '000', '99999999999999999999', 'abc', '']) {
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: v } });
expect(r.status).toBe(0);
expect(r.stdout).toContain('REASON=chromium-install\n');
}
// A wedged installer under "000" is still bounded by the DEFAULT, so with a
// 1s override written as "0001" it is killed and classified as a timeout.
const bounded = runBlock({ probe: 'fail', bunx: 'sleep 30', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '0001' } });
expect(bounded.stdout).toContain('REASON=chromium-install-timeout\n');
}, 30_000);
});
/**
* .gstack-owned marker (#2119): Windows installs COPY SKILL.md, so there is no
* symlink to readlink. link_claude_skill_dirs writes the marker; gstack-relink
* and cleanup_old_claude_symlinks prove provenance by it instead of by name.
*/
function runLinker(opts: {
isWindows: '0' | '1';
payload: string[];
plant?: (skills: string, payload: string) => void;
}): { status: number; stdout: string; stderr: string; skills: string; payload: string; tmp: string } {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-marker-'));
const skills = path.join(tmp, 'skills');
const payload = path.join(skills, 'gstack');
fs.mkdirSync(payload, { recursive: true });
for (const name of opts.payload) {
fs.mkdirSync(path.join(payload, name));
fs.writeFileSync(path.join(payload, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ${name}\n`);
}
opts.plant?.(skills, payload);
const script = [
'set -e',
`IS_WINDOWS=${opts.isWindows}`,
'SKILL_PREFIX=0',
// Keep the operator's real render dir out of the picture.
`GSTACK_USER_RENDER_DIR="${path.join(tmp, 'no-render')}"`,
'_link_skill_runtime_assets() { :; }',
'_print_windows_copy_note_once() { :; }',
'_FOREIGN_SKIPPED_ENTRIES=()',
extractFn('_link_or_copy'),
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_claude_entry_is_ours'),
extractFn('_write_owned_marker'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_backup_skill_md'),
extractFn('_cleanup_weak_dir'),
extractFn('_gstack_dir_only_links'),
extractFn('_cleanup_linked_dir'),
'_BACKED_UP_SKILL_MDS=()',
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
extractFn('link_claude_skill_dirs'),
extractFn('cleanup_old_claude_symlinks'),
`link_claude_skill_dirs "${payload}" "${skills}"`,
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"',
].join('\n');
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 };
}
describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership marker for Windows copy installs (#2119)', () => {
test('the marker is written beside a COPIED SKILL.md (IS_WINDOWS=1) and beside a symlinked one (IS_WINDOWS=0): a directory we created is ours on every platform', () => {
const win = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
try {
expect(win.status).toBe(0);
for (const name of ['qa', 'ship']) {
const md = path.join(win.skills, name, 'SKILL.md');
expect(fs.lstatSync(md).isSymbolicLink()).toBe(false);
expect(fs.existsSync(path.join(win.skills, name, '.gstack-owned'))).toBe(true);
}
expect(win.stdout).toContain('linked skills: qa ship');
} finally {
fs.rmSync(win.tmp, { recursive: true, force: true });
}
const unix = runLinker({ isWindows: '0', payload: ['qa'] });
try {
expect(unix.status).toBe(0);
expect(fs.lstatSync(path.join(unix.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(true);
} finally {
fs.rmSync(unix.tmp, { recursive: true, force: true });
}
});
test('marker writer → cleanup reader: copies the Windows linker marked are reaped on a mode flip; an unmarked same-name skill survives', () => {
// Phase 1: a Windows install links qa + ship (copies + markers).
const r = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
try {
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(r.skills, 'qa', '.gstack-owned'))).toBe(true);
expect(fs.existsSync(path.join(r.skills, 'ship', '.gstack-owned'))).toBe(true);
// Phase 2: the payload now also ships `review`, and the user has their
// OWN hand-written `review` (no marker, not byte-identical, no generated
// header) plus an unrelated `my-own`. A mode flip runs the cleanup.
fs.mkdirSync(path.join(r.payload, 'review'));
fs.writeFileSync(path.join(r.payload, 'review', 'SKILL.md'), '---\nname: review\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# review\n');
fs.mkdirSync(path.join(r.skills, 'review'));
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 = 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'), { timeout: 10_000 });
expect(flip.status).toBe(0);
expect(flip.stdout).toContain('cleaned up old entries:');
expect(flip.stdout).toContain('qa');
expect(flip.stdout).toContain('ship');
expect(flip.stdout).not.toContain('review');
expect(fs.readdirSync(r.skills).sort()).toEqual(['gstack', 'my-own', 'review']);
expect(fs.readFileSync(path.join(r.skills, 'review', 'SKILL.md'), 'utf-8')).toContain('mine, hand-written');
} finally {
fs.rmSync(r.tmp, { recursive: true, force: true });
}
});
});
+23 -1
View File
@@ -7,6 +7,11 @@
* gen-skill-docs never deletes stale out-dir entries; setup is the one place
* every host install passes through. Behavior fixture: extract the helper and
* its gate from setup, run it against a temp tree.
*
* Ownership (#2119): a symlink into our render tree goes outright; a REAL
* host directory proven only by the generated banner is weak proof, so only
* our SKILL.md, marker and asset links are removed (_cleanup_weak_dir) and the
* user's own files next to them survive.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
@@ -36,7 +41,7 @@ function mk(t: string) {
fs.writeFileSync(path.join(src, s, 'SKILL.md.tmpl'), 'x');
}
// Generated tree: live renders + two retired ones + the gstack sidecar.
for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack']) {
for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack-extra', 'gstack']) {
fs.mkdirSync(path.join(gen, g), { recursive: true });
fs.writeFileSync(path.join(gen, g, 'SKILL.md'), `${BANNER}# ${g}\n`);
}
@@ -48,12 +53,23 @@ function mk(t: string) {
fs.writeFileSync(path.join(host, 'gstack-gone', 'SKILL.md'), `${BANNER}copy\n`);
fs.mkdirSync(path.join(host, 'gstack-mine'));
fs.writeFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), '---\nname: mine\n---\nuser skill\n');
// Bannered real copy of a retired render with the user's own file beside it:
// weak proof covers only SKILL.md, so notes.md must survive (#2119).
fs.mkdirSync(path.join(host, 'gstack-extra'));
fs.writeFileSync(path.join(host, 'gstack-extra', 'SKILL.md'), `${BANNER}copy\n`);
fs.writeFileSync(path.join(host, 'gstack-extra', 'notes.md'), 'my notes\n');
return { src, gen, host };
}
function runPrune(src: string, gen: string, host?: string) {
const script = [
'set -e',
// _cleanup_weak_dir and the helpers it leans on come from main's ownership
// gate; the prune routes bannered real dirs through it.
extractFn('_gstack_link_target_abs'),
extractFn('_gstack_target_is_ours'),
extractFn('_backup_skill_md'),
extractFn('_cleanup_weak_dir'),
extractFn('_owned_for_windows_refresh'),
extractFn('_prune_stale_generated'),
`_prune_stale_generated "${src}" "${gen}" ${host ? `"${host}"` : ''}`,
@@ -89,6 +105,12 @@ describe('setup: _prune_stale_generated', () => {
expect(fs.existsSync(path.join(host, 'gstack-oldskill'))).toBe(false);
expect(fs.lstatSync(path.join(host, 'gstack-oldskill'), { throwIfNoEntry: false })).toBeUndefined();
expect(fs.existsSync(path.join(host, 'gstack-gone'))).toBe(false);
// Bannered copy with the user's own file next to it: only our SKILL.md
// goes, the file and the directory stay, and setup says so.
expect(r.stdout).toContain('pruned retired skill: gstack-extra');
expect(r.stdout).toContain('cleaned gstack-extra/SKILL.md (other files in that directory were left in place)');
expect(fs.existsSync(path.join(host, 'gstack-extra', 'SKILL.md'))).toBe(false);
expect(fs.readFileSync(path.join(host, 'gstack-extra', 'notes.md'), 'utf-8')).toBe('my notes\n');
// Live link and the user's own (unbannered) dir: untouched.
expect(fs.lstatSync(path.join(host, 'gstack-qa')).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), 'utf-8')).toContain('user skill');
+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 };
}
+5 -5
View File
@@ -14,10 +14,10 @@
* compares LIVE eval runs (tool calls, turns, cost); this one compares
* static SKILL.md sizes. Both gate-tier.
*
* Baseline rebased v1.69.1.0 v1.80.0.0: the Aside-first browser contract
* Baseline rebased v1.69.1.0 v1.81.0.0: the Aside-first browser contract
* ({{ASIDE_SETUP}}) plus the gstack-browser fallback block now ride in every
* browsing skill (~9KB), which pushed benchmark and scrape past 1.5× of the
* v1.69.1.0 anchor. Deliberate, corpus-wide, receipted in the v1.80.0.0
* v1.69.1.0 anchor. Deliberate, corpus-wide, receipted in the v1.81.0.0
* CHANGELOG; the v1.69.1.0 fixture stays on disk for history.
*
* The previous baseline lived at test/fixtures/parity-baseline-v1.69.1.0.json,
@@ -48,7 +48,7 @@ import { logBudgetOverride } from './helpers/budget-override';
import { CARVED_SKILLS } from './helpers/carve-guards';
const REPO_ROOT = path.resolve(import.meta.dir, '..');
const BASELINE_PATH = path.join(REPO_ROOT, 'test', 'fixtures', 'parity-baseline-v1.80.0.0.json');
const BASELINE_PATH = path.join(REPO_ROOT, 'test', 'fixtures', 'parity-baseline-v1.81.0.0.json');
// Default per-skill ratio is 1.50 (50% growth tolerance). Adjusted v1.52.0.0
// (cathedral cap audit) from 1.05 → 1.50: a 5% ratio tripped on legitimate
@@ -68,11 +68,11 @@ interface Regression {
}
describe('SKILL.md size budget regression (gate, free)', () => {
test('parity-baseline-v1.80.0.0.json exists', () => {
test('parity-baseline-v1.81.0.0.json exists', () => {
expect(fs.existsSync(BASELINE_PATH)).toBe(true);
});
test('no skill exceeds v1.80.0.0 baseline size × ratio', () => {
test('no skill exceeds v1.81.0.0 baseline size × ratio', () => {
const baseline: ParityBaseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const current = captureBaseline({ repoRoot: REPO_ROOT });
+14
View File
@@ -332,6 +332,20 @@ describe('gstack-telemetry-log', () => {
});
describe('.pending marker', () => {
test('--no-sweep (one-shot setup events) leaves other sessions\' in-flight markers untouched', () => {
setConfig('telemetry', 'anonymous');
const analyticsDir = path.join(tmpDir, 'analytics');
fs.mkdirSync(analyticsDir, { recursive: true });
const marker = path.join(analyticsDir, '.pending-live-456');
fs.writeFileSync(marker, '{"skill":"ship","ts":"2026-09-04T00:00:00Z","session_id":"live-456","gstack_version":"1.79.0.0"}');
run(`${BIN}/gstack-telemetry-log --event-type onboarding --skill _setup_playwright --outcome chromium-install --no-sweep`);
expect(fs.existsSync(marker)).toBe(true);
const events = parseJsonl();
expect(events).toHaveLength(1);
expect(events[0].event_type).toBe('onboarding');
expect(events[0].outcome).toBe('chromium-install');
});
test('finalizes stale .pending from another session as outcome:unknown', () => {
setConfig('telemetry', 'anonymous');
+17 -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.
@@ -175,10 +176,23 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
extractFn(SETUP_SRC, '_link_or_copy'),
extractFn(SETUP_SRC, '_print_windows_copy_note_once'),
extractFn(SETUP_SRC, '_link_skill_runtime_assets'),
extractFn(SETUP_SRC, '_gstack_link_target_abs'),
extractFn(SETUP_SRC, '_gstack_target_is_ours'),
extractFn(SETUP_SRC, '_gstack_generated_header'),
extractFn(SETUP_SRC, '_claude_entry_owned_strongly'),
extractFn(SETUP_SRC, '_claude_entry_is_ours'),
extractFn(SETUP_SRC, '_write_owned_marker'),
extractFn(SETUP_SRC, '_backup_skill_md'),
extractFn(SETUP_SRC, '_cleanup_weak_dir'),
extractFn(SETUP_SRC, '_gstack_dir_only_links'),
extractFn(SETUP_SRC, '_cleanup_linked_dir'),
'_FOREIGN_SKIPPED_ENTRIES=()',
'_BACKED_UP_SKILL_MDS=()',
`_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`,
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');