From d02e29259a14a01123f7e8ee40690670dccfeab8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 10:41:54 -0400 Subject: [PATCH] fix(#856): remove gsd-cmd-rewrites temp dirs after install (#862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#856): remove gsd-cmd-rewrites temp dirs after install applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under os.tmpdir() (gsd-cmd-rewrites-*) with rewritten command markdown. installRuntimeArtifacts() copied from it but never removed it, leaking one temp dir per commands kind per install — on tmpfs /tmp hosts these accumulate and consume RAM-backed storage. Wrap the per-kind copy in try/finally and rmSync the temp dir (only when it differs from the staged source, i.e. the commands kind) once the copy completes or fails. dest creation moved inside the try so a mkdir failure still triggers cleanup. Regression test isolates os.tmpdir() to a private TMPDIR root and asserts no gsd-cmd-rewrites-* dir survives the install. Co-Authored-By: Claude Opus 4.8 * docs(#856): add changeset for installer temp-dir cleanup Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (cherry picked from commit ac56672dba708ae3d604cc605ac7d3f420dfee56) --- .changeset/humble-deer-gather.md | 5 ++ bin/install.js | 102 +++++++++++++----------- tests/enh-790-augment-commands.test.cjs | 35 ++++++++ 3 files changed, 95 insertions(+), 47 deletions(-) create mode 100644 .changeset/humble-deer-gather.md diff --git a/.changeset/humble-deer-gather.md b/.changeset/humble-deer-gather.md new file mode 100644 index 000000000..4fa6bfa35 --- /dev/null +++ b/.changeset/humble-deer-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 862 +--- +**Installer no longer leaks `gsd-cmd-rewrites-*` temp directories.** Each install that emitted slash commands left one `fs.mkdtempSync` directory under the system temp root; on `tmpfs` `/tmp` hosts these accumulated and consumed RAM-backed storage. `installRuntimeArtifacts()` now removes the temp copy in a `finally` once command files are copied. diff --git a/bin/install.js b/bin/install.js index 1794edc52..50ba46122 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7568,59 +7568,67 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { // Returns a temp dir with rewritten content so source files are never mutated. stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix); } - const dest = path.join(layout.configDir, kind.destSubpath); - fs.mkdirSync(dest, { recursive: true }); + // applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under + // os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856). + const tempToClean = stagedForCopy !== staged ? stagedForCopy : null; + try { + const dest = path.join(layout.configDir, kind.destSubpath); + fs.mkdirSync(dest, { recursive: true }); + if (kind.kind === 'skills' && fs.existsSync(dest)) { + // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it, + // then restore after. This preserves user dirs across a wipe-and-replace + // install (#2973 / #3664). + // + // For prefix='' (Hermes): _removeGsdEntries wipes the entire dest dir (skills/gsd/). + // Preserve every subdir that is NOT in the staged set — those are user-added dirs + // (e.g. user-content/) that GSD does not manage. + // + // For prefix='gsd-' (others): _removeGsdEntries removes only gsd-* entries. + // Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only preserve the + // explicit user-owned GSD-prefixed skill gsd-dev-preferences, which GSD does not + // reinstall from source but must survive the prune (#2973). + const toPreserve = new Map(); // dirName -> Map - if (kind.kind === 'skills' && fs.existsSync(dest)) { - // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it, - // then restore after. This preserves user dirs across a wipe-and-replace - // install (#2973 / #3664). - // - // For prefix='' (Hermes): _removeGsdEntries wipes the entire dest dir (skills/gsd/). - // Preserve every subdir that is NOT in the staged set — those are user-added dirs - // (e.g. user-content/) that GSD does not manage. - // - // For prefix='gsd-' (others): _removeGsdEntries removes only gsd-* entries. - // Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only preserve the - // explicit user-owned GSD-prefixed skill gsd-dev-preferences, which GSD does not - // reinstall from source but must survive the prune (#2973). - const toPreserve = new Map(); // dirName -> Map + if (kind.prefix === '') { + // Hermes: wipes entire dest dir — preserve anything not in staged. + const stagedNames = fs.existsSync(stagedForCopy) + ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name)) + : new Set(); + for (const entry of fs.readdirSync(dest, { withFileTypes: true })) { + if (!entry.isDirectory() || stagedNames.has(entry.name)) continue; + const snap = _snapshotDir(path.join(dest, entry.name)); + if (snap.size > 0) toPreserve.set(entry.name, snap); + } + } else { + // Non-Hermes: only preserve explicitly user-owned GSD-prefixed skill dirs. + // gsd-dev-preferences is the sole user-customisable skill in this category. + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + } - if (kind.prefix === '') { - // Hermes: wipes entire dest dir — preserve anything not in staged. - const stagedNames = fs.existsSync(stagedForCopy) - ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true }) - .filter(e => e.isDirectory()).map(e => e.name)) - : new Set(); - for (const entry of fs.readdirSync(dest, { withFileTypes: true })) { - if (!entry.isDirectory() || stagedNames.has(entry.name)) continue; - const snap = _snapshotDir(path.join(dest, entry.name)); - if (snap.size > 0) toPreserve.set(entry.name, snap); + _removeGsdEntries(dest, kind); + _copyStaged(stagedForCopy, dest, kind); + + // Restore user-owned dirs after the prune+copy + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); } } else { - // Non-Hermes: only preserve explicitly user-owned GSD-prefixed skill dirs. - // gsd-dev-preferences is the sole user-customisable skill in this category. - const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; - for (const dirName of USER_OWNED_SKILL_DIRS) { - const skillDir = path.join(dest, dirName); - if (!fs.existsSync(skillDir)) continue; - const snap = _snapshotDir(skillDir); - if (snap.size > 0) toPreserve.set(dirName, snap); - } + // For non-skills kinds (commands, agents): no user content to preserve; + // just prune stale gsd-* entries and copy new ones. + _removeGsdEntries(dest, kind); + _copyStaged(stagedForCopy, dest, kind); } - - _removeGsdEntries(dest, kind); - _copyStaged(stagedForCopy, dest, kind); - - // Restore user-owned dirs after the prune+copy - for (const [dirName, snap] of toPreserve) { - _restoreDir(path.join(dest, dirName), snap); + } finally { + if (tempToClean) { + try { fs.rmSync(tempToClean, { recursive: true, force: true }); } catch { /* best-effort */ } } - } else { - // For non-skills kinds (commands, agents): no user content to preserve; - // just prune stale gsd-* entries and copy new ones. - _removeGsdEntries(dest, kind); - _copyStaged(stagedForCopy, dest, kind); } } } diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs index ba2e53d74..95f772000 100644 --- a/tests/enh-790-augment-commands.test.cjs +++ b/tests/enh-790-augment-commands.test.cjs @@ -135,6 +135,41 @@ describe('enh-790 — installRuntimeArtifacts augment emits both commands and sk }); }); +describe('enh-790 — installRuntimeArtifacts does not leak temp dirs', () => { + test('install cleans up gsd-cmd-rewrites-* temp dirs (no leak) — #856', (t) => { + const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + + // Isolate os.tmpdir() to a private root so parallel test processes can't race + // on the shared system temp dir. os.tmpdir() resolves $TMPDIR/$TEMP/$TMP per call. + const isolatedTmp = createTempDir('gsd-enh790-tmproot-'); + const prev = { TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP }; + process.env.TMPDIR = isolatedTmp; + process.env.TEMP = isolatedTmp; + process.env.TMP = isolatedTmp; + + const configDir = createTempDir('gsd-enh790-leak-'); + t.after(() => { + for (const k of ['TMPDIR', 'TEMP', 'TMP']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + cleanup(configDir); + cleanup(isolatedTmp); + }); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); + + // The install creates its gsd-cmd-rewrites-* temp dirs under the isolated root; + // after the fix none must remain. + const leaked = fs.readdirSync(isolatedTmp).filter(n => n.startsWith('gsd-cmd-rewrites-')); + assert.ok( + leaked.length === 0, + `installer must not leak gsd-cmd-rewrites-* temp dirs; leaked: ${leaked.join(', ')}` + ); + }); +}); + // ─── Uninstall contract ────────────────────────────────────────────────────── describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => {