fix(#856): remove gsd-cmd-rewrites temp dirs after install (#862)

* 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 <noreply@anthropic.com>

* docs(#856): add changeset for installer temp-dir cleanup

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 10:41:54 -04:00
committed by GitHub
parent a5d82bf98a
commit ac56672dba
3 changed files with 95 additions and 47 deletions

View File

@@ -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.

View File

@@ -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<relPath, Buffer>
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<relPath, Buffer>
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);
}
}
}

View File

@@ -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', () => {