* test(#2470): failing-first — pi extension must satisfy pi's auto-discovery filter pi auto-discovers extensions/ entries through isExtensionFile(), which accepts only .ts and .js. GSD installs its extension as gsd.cjs, so pi silently skips it: no /gsd command, no error, no log line. Encodes pi's discovery PREDICATE rather than a literal filename, so the contract keeps holding across future renames, and adds the migration-006 test matrix for retiring the stale gsd.cjs left in pre-fix installs. Red until the fix lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2470): install pi's extension as gsd.js so pi actually discovers it pi auto-discovers extensions/ entries via isExtensionFile(), which accepts only .ts and .js and skips everything else silently. capabilities/pi declared the dest as gsd.cjs, so the extension installed correctly and was then ignored forever: no /gsd command, no error, no log line. Install it as gsd.js. The in-repo source stays pi/gsd.cjs — tests require() it directly and .cjs is unambiguous CommonJS; only the installed name has to satisfy pi, and pi loads accepted files through jiti, which handles CJS and ESM alike. (The reporter's premise that ~/.pi/agent/package.json declares "type":"commonjs" does not hold — pi never writes that file.) Renaming an installed artifact requires a migration record, so add 006 to retire the stale gsd.cjs from pre-fix installs; without it the old path drops out of the manifest and uninstall can never remove it. The migration plans nothing for an unmanifested gsd.cjs: emitting remove-managed there would have the executor downgrade it to preserve-user and mark it blocked, failing the install for anyone who hand-placed their own file. Also pins body-parser >=2.3.0 (GHSA-v422-hmwv-36x6). The advisory reaches the production tree transitively via the Claude Agent SDK and fails the npm-integrity gate, blocking any PR; pinned via the existing overrides idiom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2470): address orthogonal review findings + register migration checksum Code review: - pi/gsd.cjs's install docstring still told readers to copy the file to extensions/gsd.cjs — the exact silently-broken state this PR fixes. Anyone following it recreated the bug. - Two stale extensions/gsd.cjs comments in install-minimal-hooks.test.cjs. Security review: - _installNativePluginIfDeclared confined nativePlugin.dir but joined nativePlugin.file onto the validated directory unchecked, so a descriptor whose file carried .., an absolute path, or a NUL byte would have written outside configHome. Not reachable in a shipped build (descriptors are first-party and compiled into the capability registry), but file is exactly the field this PR changes. Confine the full dest path instead; for a well-formed descriptor this resolves identically to the previous mkdir(dir) + join(dir, file). Covered by four new write-confinement tests. Also register migration 006 in the #670 EXPECTED_CHECKSUMS baseline — shipped migration bodies are locked to a committed checksum and a new migration fails CI until it is listed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2470): never dereference a symlinked managed path when snapshotting fs.copyFileSync follows symlinks, so a managed path replaced by a link had the REFERENT's bytes copied into the migration journal's rollback and backup trees — a gsd.cjs symlinked at a private key would land that key's contents under gsd-migration-journal/. Deletion was already safe (fs.rmSync unlinks the link, never the target); the copy was not. Nothing GSD installs is ever a symlink, so the faithful snapshot of a symlinked managed path is the link itself. copyPreservingSymlink recreates it, which keeps rollback fidelity (restore re-creates the same link) while never reading the referent. Scoped the pre-delete to the symlink branch only, so the regular-file path keeps copyFileSync's overwrite-in-place and a mid-restore failure cannot destroy the destination. The restore-side existence check moves to lstat, since existsSync follows a link whose target is gone and would silently skip the restore. This lives in the engine all six migrations share, so 000-005 are hardened too. Also regenerates the pi golden-parity hash: correcting pi/gsd.cjs's own install docstring changes the extension's content, which the golden suite caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2470): symlink-preserve the in-apply failure-recovery restore too The previous commit routed three copy sites through copyPreservingSymlink but missed a fourth: the catch block inside applyInstallerMigrationPlan, which replays rollback snapshots taken earlier in the SAME apply attempt. Those snapshots are symlinks precisely because of that commit, so the raw copyFileSync there dereferenced them and wrote the referent's bytes to the LIVE install path — worse than the journal-tree leak it was meant to fix, since it is user-visible and at a predictable location. Verified by experiment rather than assertion: with the pre-fix line restored, the managed path comes back as a REGULAR FILE containing the referent's bytes; with the fix it comes back as a symlink and the bytes appear nowhere. The accompanying test injects the failure by letting the delete succeed and then throwing once, modelling a later step failing after the delete. That ordering is load-bearing — an earlier draft injected before the delete, which leaves the live path in place, so the pre-fix copyFileSync hit a same-file collision and threw instead of leaking. That draft passed against the bug it was written to catch; this one fails against it. Adds the missing rollback() coverage as well: a restored symlinked managed path must come back as a link pointing at its original target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2470): read the backup location from the journal, not the plan The new backup-content assertion read backupRelPath off result.plan.actions, where it is always null: the planner reserves the field and apply chooses the concrete location, recording it in the journal. The assertion therefore failed on "backup path must be recorded for the user" rather than on anything about the behavior it was written to check. Read it from the journal, which is the authoritative record. Verified by executing all four new test bodies in-process against the built engine — the backup file exists and holds the locally patched content. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#2470): backfill changeset pr number to 2478 * chore(#2470): backfill changeset pr number to 2478 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2813 lines
103 KiB
JavaScript
2813 lines
103 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const {
|
|
applyInstallerMigrationPlan,
|
|
classifyArtifact,
|
|
discoverInstallerMigrations,
|
|
INSTALL_STATE_NAME,
|
|
migrationChecksum,
|
|
planInstallerMigrations,
|
|
readInstallState,
|
|
runInstallerMigrations,
|
|
writeInstallState,
|
|
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const firstTimeBaselineMigration = require('../gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs');
|
|
const opencodeBaselineCommandsDirMigration = require('../gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs');
|
|
|
|
function createTempInstall() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-'));
|
|
}
|
|
|
|
function cleanup(dir) {
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup predates helpers.cjs; name collision prevents import
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
function sha256(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function writeFile(root, relPath, content) {
|
|
const fullPath = path.join(root, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
}
|
|
|
|
function writeManifest(root, files) {
|
|
fs.writeFileSync(
|
|
path.join(root, 'gsd-file-manifest.json'),
|
|
JSON.stringify({
|
|
version: '1.49.0',
|
|
timestamp: '2026-05-10T00:00:00.000Z',
|
|
mode: 'full',
|
|
files,
|
|
}, null, 2),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function migrationRecord(overrides = {}) {
|
|
return {
|
|
id: '2026-05-11-remove-old-hook',
|
|
title: 'Remove retired hook',
|
|
description: 'Remove retired hook',
|
|
introducedIn: '1.50.0',
|
|
scopes: ['global', 'local'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function legacyCodexHook(configDir) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function userHook(command) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
test('records a first-time baseline while preserving user-owned artifacts', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'gsd-core/workflows/plan.md', 'managed workflow\n');
|
|
writeFile(configDir, 'gsd-core/USER-PROFILE.md', 'user profile\n');
|
|
writeManifest(configDir, {
|
|
'gsd-core/workflows/plan.md': sha256('managed workflow\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-first-time-baseline-scan']);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'gsd-core/workflows/plan.md'), 'utf8'), 'managed workflow\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'gsd-core/USER-PROFILE.md'), 'utf8'), 'user profile\n');
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'gsd-core/workflows/plan.md',
|
|
classification: 'managed-pristine',
|
|
},
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'gsd-core/USER-PROFILE.md',
|
|
classification: 'user-owned',
|
|
},
|
|
]
|
|
);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves unknown files discovered in known install surfaces by default', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.blocked, undefined);
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
classification: 'unknown',
|
|
},
|
|
]
|
|
);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom-user-hook.js'), 'utf8'), 'user hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves user-owned skill files during baseline without hashing their content', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalOpenSync = fs.openSync;
|
|
t.after(() => {
|
|
fs.openSync = originalOpenSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
writeFile(configDir, 'skills/custom-user-skill/SKILL.md', 'user skill\n');
|
|
writeManifest(configDir, {});
|
|
const userSkillPath = path.join(configDir, 'skills/custom-user-skill/SKILL.md');
|
|
fs.openSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(userSkillPath)) {
|
|
throw new Error('user-owned skill content should not be hashed during baseline');
|
|
}
|
|
return originalOpenSync.call(fs, filePath, ...args);
|
|
};
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
currentHash: action.currentHash,
|
|
})),
|
|
[
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'skills/custom-user-skill/SKILL.md',
|
|
classification: 'user-owned',
|
|
currentHash: null,
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
test('blocks stale GSD-looking baseline artifacts for explicit user choice', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/gsd-retired-hook.js', 'old gsd hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:02.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(result.journalRelPath, null);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/gsd-retired-hook.js'), 'utf8'), 'old gsd hook\n');
|
|
assert.deepEqual(
|
|
result.blocked.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
choices: action.choices,
|
|
})),
|
|
[
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'hooks/gsd-retired-hook.js',
|
|
classification: 'stale-gsd-looking',
|
|
choices: ['keep', 'remove'],
|
|
},
|
|
]
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('records known generated agent artifacts so profile cleanup can remove them', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'agents/gsd-executor.md', 'old generated agent\n');
|
|
writeFile(configDir, 'agents/gsd-executor.toml', 'old generated agent config\n');
|
|
writeFile(configDir, 'agents/gsd-local-experiment.md', 'user experiment\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:03.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'agents/gsd-executor.md',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'agents/gsd-executor.toml',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'agents/gsd-local-experiment.md',
|
|
classification: 'stale-gsd-looking',
|
|
},
|
|
]
|
|
);
|
|
assert.deepEqual(result.blocked.map((action) => action.relPath), ['agents/gsd-local-experiment.md']);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.md'), 'utf8'), 'old generated agent\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.toml'), 'utf8'), 'old generated agent config\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-local-experiment.md'), 'utf8'), 'user experiment\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration 005: OpenCode commands/ (plural) baseline scan (#2329 follow-up)
|
|
//
|
|
// 000-first-time-baseline.cts's RUNTIME_SURFACES.opencode is a shipped,
|
|
// immutable body that still only names the legacy singular `command/`
|
|
// directory (see docs/installer-migrations.md#state-files). These tests
|
|
// pin migration 005's widened scan of the plural `commands/` surface.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('baselines pre-existing OpenCode commands/ files: managed, unknown, and stale-GSD-looking', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'commands/my-custom-command.md', 'user command\n');
|
|
writeFile(configDir, 'commands/gsd-retired-command.md', 'stale gsd-looking file, not in manifest\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'commands/gsd-plan-phase.md',
|
|
classification: 'managed-pristine',
|
|
},
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'commands/my-custom-command.md',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'commands/gsd-retired-command.md',
|
|
classification: 'stale-gsd-looking',
|
|
},
|
|
]
|
|
);
|
|
// The stale-GSD-looking file blocks the plan (needs explicit user choice),
|
|
// so nothing was applied and no install state was written yet.
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
// Every file on disk is untouched — baseline-preserve-user/record-baseline/
|
|
// prompt-user are all non-mutating classification actions.
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-retired-command.md'), 'utf8'), 'stale gsd-looking file, not in manifest\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('OpenCode commands/ baseline is idempotent — a second run does not re-plan already-applied files', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'commands/my-custom-command.md', 'user command\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
});
|
|
|
|
const first = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:01.000Z',
|
|
});
|
|
assert.deepEqual(first.appliedMigrationIds, ['2026-07-17-opencode-baseline-commands-dir']);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-07-17-opencode-baseline-commands-dir',
|
|
]);
|
|
|
|
// Second run: the migration id is now applied, so it must never re-run,
|
|
// regardless of what baselineScan is passed.
|
|
const second = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:02.000Z',
|
|
});
|
|
assert.deepEqual(second.appliedMigrationIds, []);
|
|
assert.deepEqual(second.plan.actions, []);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-07-17-opencode-baseline-commands-dir',
|
|
]);
|
|
// Files remain untouched across both runs.
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('OpenCode commands/ baseline migration is scoped to opencode and never plans for Kilo', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Kilo's descriptor keeps the singular `command/` dir; a `commands/` (plural)
|
|
// directory here would be unrelated to Kilo's install surface. This proves the
|
|
// migration's `runtimes: ['opencode']` scoping keeps Kilo installs untouched.
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'command/gsd-plan-phase.md', 'kilo managed command\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
'command/gsd-plan-phase.md': sha256('kilo managed command\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'kilo',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:03.000Z',
|
|
});
|
|
|
|
// migrationMatchesContext filters this migration out entirely for kilo
|
|
// (runtimes: ['opencode']) before plan() is ever invoked: it is not
|
|
// "pending", produces zero actions, is never applied, and no install-state
|
|
// file is written for this run at all.
|
|
assert.deepEqual(result.plan.actions, []);
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'command/gsd-plan-phase.md'), 'utf8'), 'kilo managed command\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('regression (#2329 follow-up): pre-existing unmanifested commands/gsd-*.md is no longer silently destroyed', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Simulates a pre-existing, non-GSD file sitting under OpenCode's commands/
|
|
// directory before GSD's first-ever migration-tracked run against this
|
|
// configDir (no manifest, no install state yet).
|
|
writeFile(configDir, 'commands/gsd-retired-plan.md', 'pre-existing file, not GSD-written\n');
|
|
|
|
// Full default migration set (all shipped migrations, including 000 AND 005),
|
|
// matching production: bin/install.js calls runInstallerMigrations with no
|
|
// explicit `migrations` override.
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:04.000Z',
|
|
});
|
|
|
|
// Before this fix, RUNTIME_SURFACES.opencode omitted `commands/`, so this file
|
|
// was invisible to every migration and ordinary materialization would delete it
|
|
// unconditionally with a clean exit. Now it is caught and blocks the install
|
|
// pending an explicit user choice — the same protection the legacy `command/`
|
|
// surface already had.
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.ok(Array.isArray(result.blocked) && result.blocked.length > 0, 'expected a blocked prompt-user action');
|
|
const blockedForFile = result.blocked.find((action) => action.relPath === 'commands/gsd-retired-plan.md');
|
|
assert.ok(blockedForFile, 'expected commands/gsd-retired-plan.md to be blocked pending user choice');
|
|
assert.equal(blockedForFile.type, 'prompt-user');
|
|
assert.equal(blockedForFile.migrationId, '2026-07-17-opencode-baseline-commands-dir');
|
|
// The file itself was never touched — migrations only classify, they do not
|
|
// mutate disk.
|
|
assert.equal(
|
|
fs.readFileSync(path.join(configDir, 'commands/gsd-retired-plan.md'), 'utf8'),
|
|
'pre-existing file, not GSD-written\n'
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('plans a pending migration against an unchanged managed file', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.equal(plan.blocked.length, 0);
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.deepEqual(
|
|
{
|
|
migrationId: plan.actions[0].migrationId,
|
|
type: plan.actions[0].type,
|
|
relPath: plan.actions[0].relPath,
|
|
reason: plan.actions[0].reason,
|
|
classification: plan.actions[0].classification,
|
|
originalHash: plan.actions[0].originalHash,
|
|
currentHash: plan.actions[0].currentHash,
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
}
|
|
);
|
|
assert.match(plan.actions[0].migrationChecksum, /^sha256:/);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('plans backup before removal for a modified managed file', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.equal(plan.blocked.length, 0);
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.equal(plan.actions[0].type, 'backup-and-remove');
|
|
assert.equal(plan.actions[0].classification, 'managed-modified');
|
|
assert.equal(plan.actions[0].originalHash, sha256('managed hook\n'));
|
|
assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n'));
|
|
assert.equal(plan.actions[0].backupRelPath, null);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('blocks removal of unknown files by preserving them by default', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture asks to retire a matching hook path',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.equal(plan.actions[0].type, 'preserve-user');
|
|
assert.equal(plan.actions[0].requestedType, 'remove-managed');
|
|
assert.equal(plan.actions[0].classification, 'unknown');
|
|
assert.deepEqual(plan.blocked, [plan.actions[0]]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('fails closed when install state JSON is malformed', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), '{ not json\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => readInstallState(configDir),
|
|
/invalid installer migration state JSON/
|
|
);
|
|
});
|
|
|
|
test('computes each migration checksum once per planned migration', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
|
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/first.js': sha256('first hook\n'),
|
|
'hooks/second.js': sha256('second hook\n'),
|
|
});
|
|
let checksumReads = 0;
|
|
const migration = {
|
|
...migrationRecord({
|
|
id: '2026-05-11-remove-two-hooks',
|
|
title: 'Remove two retired hooks',
|
|
description: 'Remove retired hooks',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/first.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/second.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
get checksum() {
|
|
checksumReads += 1;
|
|
return 'sha256:precomputed';
|
|
},
|
|
};
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [migration],
|
|
scope: 'global',
|
|
});
|
|
|
|
assert.equal(plan.actions.length, 2);
|
|
assert.equal(checksumReads, 1);
|
|
});
|
|
|
|
test('tolerates an applied-migration checksum drift instead of aborting the upgrade', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// A migration that a prior release recorded as applied under a DIFFERENT body,
|
|
// so the stored checksum no longer matches the current computed checksum.
|
|
const migration = migrationRecord({ id: '2026-05-11-remove-old-hook' });
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
appliedAt: '2026-01-01T00:00:00.000Z',
|
|
journal: null,
|
|
checksum: 'sha256:stale-pre-1-3-0-value',
|
|
},
|
|
],
|
|
});
|
|
|
|
// Planning must NOT throw, must skip the already-applied migration, and must
|
|
// surface the drift on the plan for downstream reconciliation.
|
|
let plan;
|
|
assert.doesNotThrow(() => {
|
|
plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [migration],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
});
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.equal(plan.actions.length, 0);
|
|
assert.ok(Array.isArray(plan.checksumDrift));
|
|
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
|
|
assert.ok(drift, 'expected checksum drift to be reported for the applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:stale-pre-1-3-0-value');
|
|
assert.match(drift.currentChecksum, /^sha256:/);
|
|
assert.notEqual(drift.currentChecksum, drift.storedChecksum);
|
|
});
|
|
|
|
test('classifies large files without loading the whole file through readFileSync', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalReadFileSync = fs.readFileSync;
|
|
t.after(() => {
|
|
fs.readFileSync = originalReadFileSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
const relPath = 'skills/gsd-large/SKILL.md';
|
|
const fullPath = path.join(configDir, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, Buffer.alloc(1024 * 1024 + 1, 'a'));
|
|
|
|
fs.readFileSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(fullPath)) {
|
|
throw new Error('large file should be streamed for hashing');
|
|
}
|
|
return originalReadFileSync.call(fs, filePath, ...args);
|
|
};
|
|
|
|
const artifact = classifyArtifact(configDir, relPath, { files: {} });
|
|
|
|
assert.equal(artifact.classification, 'unknown');
|
|
assert.match(artifact.currentHash, /^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
test('applies an unblocked plan with a journal and install-state update', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false);
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.match(
|
|
result.journalRelPath,
|
|
/^gsd-migration-journal\/2026-05-11T00-00-01-000Z-[0-9a-f]+\.json$/
|
|
);
|
|
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.equal(journal.actions[0].relPath, 'hooks/old-hook.js');
|
|
|
|
const state = readInstallState(configDir);
|
|
assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']);
|
|
assert.match(state.appliedMigrations[0].checksum, /^sha256:/);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('uses unique journal paths for applies that share a timestamp', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
|
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/first.js': sha256('first hook\n'),
|
|
'hooks/second.js': sha256('second hook\n'),
|
|
});
|
|
const now = () => '2026-05-11T00:00:09.000Z';
|
|
|
|
const first = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [{
|
|
migrationId: 'first-migration',
|
|
migrationChecksum: 'sha256:first',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/first.js',
|
|
reason: 'first',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('first hook\n'),
|
|
currentHash: sha256('first hook\n'),
|
|
}],
|
|
},
|
|
now,
|
|
});
|
|
const second = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [{
|
|
migrationId: 'second-migration',
|
|
migrationChecksum: 'sha256:second',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/second.js',
|
|
reason: 'second',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('second hook\n'),
|
|
currentHash: sha256('second hook\n'),
|
|
}],
|
|
},
|
|
now,
|
|
});
|
|
|
|
assert.notEqual(first.journalRelPath, second.journalRelPath);
|
|
assert.equal(fs.existsSync(path.join(configDir, first.journalRelPath)), true);
|
|
assert.equal(fs.existsSync(path.join(configDir, second.journalRelPath)), true);
|
|
});
|
|
|
|
test('stores modified-file backups under the unique migration run journal', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:10.000Z',
|
|
});
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const backupRelPath = journal.actions[0].backupRelPath;
|
|
|
|
assert.match(backupRelPath, /^gsd-migration-journal\/2026-05-11T00-00-10-000Z-[0-9a-f]+-backups\/hooks\/old-hook\.js$/);
|
|
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user changed hook\n');
|
|
});
|
|
|
|
test('successful migration rollback removes run-scoped backup directories', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:11.000Z',
|
|
});
|
|
|
|
result.rollback();
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
|
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
|
assert.equal(
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) => name.includes('backups')),
|
|
false
|
|
);
|
|
});
|
|
|
|
test('refuses to run migrations while another installer owns the migration lock', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
fs.writeFileSync(path.join(configDir, 'gsd-install-migration.lock'), 'held by test\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 0,
|
|
}),
|
|
/installer migration lock is held/
|
|
);
|
|
});
|
|
|
|
test('reports lock release failures after migration work completes', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
t.after(() => {
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// The release closure uses fs.unlinkSync (not fs.rmSync) so that EPERM is
|
|
// NOT silently swallowed on Windows (#3670). Mock unlinkSync to simulate
|
|
// a Windows NTFS EPERM condition when the lock file is removed.
|
|
fs.unlinkSync = (targetPath) => {
|
|
if (path.basename(String(targetPath)) === 'gsd-install-migration.lock') {
|
|
throw new Error('simulated lock unlink failure');
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
};
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
}),
|
|
/failed to release installer migration lock/
|
|
);
|
|
});
|
|
|
|
test('rollback handle restores files and install state after a successful apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: 'already-applied',
|
|
appliedAt: '2026-05-10T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-remove-old-hook',
|
|
title: 'Remove retired hook',
|
|
description: 'Remove retired hook',
|
|
introducedIn: '1.50.0',
|
|
scopes: ['global'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
result.rollback();
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-applied']);
|
|
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rolls back touched files and leaves state unchanged when apply fails', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = {
|
|
pendingMigrationIds: ['2026-05-11-remove-old-hook'],
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:02.000Z',
|
|
}),
|
|
/unsupported migration action type/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations, []);
|
|
assert.equal(
|
|
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
|
name.startsWith('2026-05-11T00-00-02-000Z')
|
|
),
|
|
false
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('cleans rollback and backup artifacts when migration apply fails', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
const plan = {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
migrationChecksum: 'sha256:remove',
|
|
type: 'backup-and-remove',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-modified',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('user changed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
migrationChecksum: 'sha256:remove',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:12.000Z',
|
|
}),
|
|
/unsupported migration action type/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
|
assert.equal(
|
|
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
|
name.startsWith('2026-05-11T00-00-12-000Z')
|
|
),
|
|
false
|
|
);
|
|
});
|
|
|
|
test('reports rollback restore failures instead of swallowing them', () => {
|
|
const configDir = createTempInstall();
|
|
const originalCopyFileSync = fs.copyFileSync;
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
fs.copyFileSync = (src, dest) => {
|
|
if (/2026-05-11T00-00-04-000Z-[0-9a-f]+-rollback/.test(String(src))) {
|
|
throw new Error('simulated rollback copy failure');
|
|
}
|
|
return originalCopyFileSync(src, dest);
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:04.000Z',
|
|
}),
|
|
(error) => {
|
|
assert.match(error.message, /rollback incomplete/);
|
|
assert.equal(error.rollbackFailures.length, 1);
|
|
assert.equal(error.rollbackFailures[0].relPath, 'hooks/old-hook.js');
|
|
return true;
|
|
}
|
|
);
|
|
} finally {
|
|
fs.copyFileSync = originalCopyFileSync;
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects executable preserve-user actions because preservation blocks non-interactive apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-preserve-user',
|
|
type: 'preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
reason: 'unknown user hook',
|
|
classification: 'unknown',
|
|
originalHash: null,
|
|
currentHash: sha256('user hook\n'),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
/unsupported migration action type: preserve-user/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('keeps prior install state intact when a state write fails mid-write', () => {
|
|
const configDir = createTempInstall();
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
try {
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'already-safe', appliedAt: '2026-05-11T00:00:00.000Z' }],
|
|
});
|
|
|
|
fs.writeFileSync = (filePath, content, ...rest) => {
|
|
if (path.basename(filePath).startsWith(`${INSTALL_STATE_NAME}.tmp-`)) {
|
|
throw new Error('simulated temp state write failure');
|
|
}
|
|
return originalWriteFileSync(filePath, content, ...rest);
|
|
};
|
|
|
|
assert.throws(
|
|
() => writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'new-migration', appliedAt: '2026-05-11T00:00:01.000Z' }],
|
|
}),
|
|
/simulated temp state write failure/
|
|
);
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
}
|
|
|
|
try {
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-safe']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('skips migration records already present in install state', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
plan: () => {
|
|
throw new Error('already-applied migration planner must not run');
|
|
},
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:03.000Z',
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.deepEqual(plan.actions, []);
|
|
assert.deepEqual(plan.blocked, []);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('marks zero-action pending migrations as applied', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-noop-cleanup',
|
|
title: 'No-op cleanup',
|
|
description: 'No-op cleanup',
|
|
destructive: false,
|
|
plan: () => [],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-noop-cleanup']);
|
|
assert.equal(result.journalRelPath, null);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-noop-cleanup',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('surfaces checksum drift for an already-applied migration without aborting', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
let plan;
|
|
assert.doesNotThrow(() => {
|
|
plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
checksum: 'sha256:new-definition',
|
|
plan: () => [],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
});
|
|
});
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.ok(Array.isArray(plan.checksumDrift));
|
|
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
|
|
assert.ok(drift, 'expected drift entry for the applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:old-definition');
|
|
assert.equal(drift.currentChecksum, 'sha256:new-definition');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('ignores checksum drift for applied migrations outside the active runtime scope', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-codex-only',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-codex-only',
|
|
title: 'Codex-only migration',
|
|
description: 'Codex-only migration',
|
|
checksum: 'sha256:new-definition',
|
|
runtimes: ['codex'],
|
|
scopes: ['global'],
|
|
plan: () => {
|
|
throw new Error('out-of-scope migration planner must not run');
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.deepEqual(plan.actions, []);
|
|
assert.deepEqual(plan.blocked, []);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('discovers migration records from a directory in filename order', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
const migrationsDir = path.join(configDir, 'migrations');
|
|
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(migrationsDir, '002-second.cjs'),
|
|
"module.exports = { id: 'second', title: 'Second', description: 'second', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, plan: () => [] };\n",
|
|
'utf8'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(migrationsDir, '001-first.cjs'),
|
|
"module.exports = { id: 'first', title: 'First', description: 'first', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, plan: () => [] };\n",
|
|
'utf8'
|
|
);
|
|
|
|
const migrations = discoverInstallerMigrations({ migrationsDir });
|
|
|
|
assert.deepEqual(migrations.map((migration) => migration.id), ['first', 'second']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects migration actions that escape the install root', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
assert.throws(
|
|
() => planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-bad-path',
|
|
title: 'Bad path',
|
|
description: 'Bad path',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/../../outside.js',
|
|
reason: 'bad path',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
}),
|
|
/relPath must stay inside configDir/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects migration actions that normalize to the install root', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
for (const relPath of ['.', 'hooks/..']) {
|
|
assert.throws(
|
|
() => planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: `2026-05-11-bad-path-${relPath.replace(/[^a-z0-9]/gi, '-')}`,
|
|
title: 'Bad path',
|
|
description: 'Bad path',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath,
|
|
reason: 'bad path',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
}),
|
|
/relPath must stay inside configDir/
|
|
);
|
|
}
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('runs discovered installer migrations against manifest-managed legacy orphan files', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeFile(configDir, 'hooks/custom.js', 'custom hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom.js'), 'utf8'), 'custom hook\n');
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-legacy-orphan-files']);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['2026-05-11-legacy-orphan-files']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('backs up modified legacy orphan files before removing them', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/statusline.js', 'user modified legacy hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: discoverInstallerMigrations({
|
|
migrationsDir: path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'),
|
|
}),
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
const action = plan.actions.find((item) => item.relPath === 'hooks/statusline.js');
|
|
|
|
assert.equal(action.type, 'backup-and-remove');
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const backupRelPath = journal.actions.find((item) => item.relPath === 'hooks/statusline.js').backupRelPath;
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user modified legacy hook\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [
|
|
legacyCodexHook(configDir),
|
|
userHook('node "/Users/example/bin/user-hook.js"'),
|
|
userHook('node "/Users/example/bin/gsd-check-update.js"'),
|
|
],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
|
|
assert.deepEqual(commands, [
|
|
'node "/Users/example/bin/user-hook.js"',
|
|
'node "/Users/example/bin/gsd-check-update.js"',
|
|
]);
|
|
assert.ok(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'));
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves unrelated empty hooks.json structure while pruning legacy Codex hooks', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [
|
|
legacyCodexHook(configDir),
|
|
{ hooks: [] },
|
|
{ metadata: null },
|
|
],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
|
|
assert.deepEqual(hooksJson.SessionStart, [
|
|
{ hooks: [] },
|
|
{ metadata: null },
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('skips runtime-specific migration records for other runtimes', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [legacyCodexHook(configDir)],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:07.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
assert.equal(hooksJson.SessionStart[0].hooks[0].command, `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`);
|
|
assert.equal(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'), false);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Checksum-baseline guardrail (issue #670)
|
|
//
|
|
// Shipped installer-migration bodies are immutable: editing a released body
|
|
// breaks the stored checksum for any user who has already applied that
|
|
// migration, which was the root cause of issue #670.
|
|
//
|
|
// This test locks every shipped migration to its committed checksum so that CI
|
|
// catches accidental body edits. When you INTENTIONALLY change the behaviour
|
|
// of a migration you must add a NEW fix-forward migration id instead; if for
|
|
// some extraordinary reason you truly need to update an existing baseline, add
|
|
// the new checksum here with a comment explaining why.
|
|
//
|
|
// Mechanism: compute each migration's checksum directly via the exported
|
|
// migrationChecksum() (scope-independent) and assert it matches the committed
|
|
// baseline. This is simpler and more robust than the previous plan()-based
|
|
// approach because it doesn't depend on runtime/scope filtering.
|
|
// ---------------------------------------------------------------------------
|
|
test('shipped installer-migration checksums are locked to a committed baseline (issue #670 guardrail)', () => {
|
|
// Committed baseline — update ONLY when adding a new migration or performing an
|
|
// extraordinary intentional body change (add a comment explaining why). Editing a
|
|
// shipped migration body breaks the stored checksum for everyone who already applied
|
|
// it (root cause of #670) — add a NEW fix-forward migration id instead.
|
|
const EXPECTED_CHECKSUMS = {
|
|
'2026-05-11-first-time-baseline-scan':
|
|
'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a',
|
|
'2026-05-11-legacy-orphan-files':
|
|
'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c',
|
|
'2026-05-11-codex-legacy-hooks-json':
|
|
'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058',
|
|
'2026-06-02-rename-get-shit-done-to-gsd-core':
|
|
'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95',
|
|
// Migration 004: prune stale gsd-pristine/get-shit-done/ snapshots (#934) // gsd-allow-legacy-name
|
|
'2026-06-09-prune-stale-pristine-get-shit-done': // gsd-allow-legacy-name
|
|
'sha256:6555dd044659276fbc204e81793cd92c5315d54e7316bcdd82d2c98d15a7e9e8',
|
|
// Migration 005 (NEW, added here per this test's own sanctioned "adding a new
|
|
// migration" case — not a shipped-body edit): baseline OpenCode's commands/
|
|
// (plural) directory during the first-time scan. #2329 moved OpenCode command
|
|
// materialization from legacy command/ to commands/, but 000's RUNTIME_SURFACES
|
|
// is a shipped, immutable body that still only names command/, so this
|
|
// fix-forward migration widens the scanned surface without touching 000.
|
|
'2026-07-17-opencode-baseline-commands-dir':
|
|
'sha256:0f6080b5f9b75fb5adbe9664a71152e23a5336813453b0a77e4df6fd483ad38e',
|
|
// Migration 006 (NEW, added here per this test's own sanctioned "adding a new
|
|
// migration" case — not a shipped-body edit): retire pi's stale
|
|
// extensions/gsd.cjs. #2470 renamed the installed extension to
|
|
// extensions/gsd.js because pi's isExtensionFile() auto-discovery accepts
|
|
// only .ts/.js and silently skips everything else; without this migration the
|
|
// old path drops out of the manifest and uninstall can never remove it.
|
|
'2026-07-20-pi-extension-cjs-to-js':
|
|
'sha256:185fa926ae24d83cbdd95c31a9ad2cc8d123e176ad543669b3b0ed75e6ca6f4a',
|
|
};
|
|
|
|
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const migrations = discoverInstallerMigrations({ migrationsDir: DEFAULT_MIGRATIONS_DIR });
|
|
const discoveredIds = new Set(migrations.map((m) => m.id));
|
|
|
|
// No stale baseline entries.
|
|
for (const id of Object.keys(EXPECTED_CHECKSUMS)) {
|
|
assert.ok(discoveredIds.has(id),
|
|
`EXPECTED_CHECKSUMS has a stale entry for '${id}' — that migration no longer exists; remove it`);
|
|
}
|
|
// Every discovered migration has a committed baseline entry.
|
|
for (const id of discoveredIds) {
|
|
assert.ok(Object.prototype.hasOwnProperty.call(EXPECTED_CHECKSUMS, id),
|
|
`new migration '${id}' has no committed checksum baseline — add it to EXPECTED_CHECKSUMS in tests/installer-migrations.test.cjs`);
|
|
}
|
|
// Core lock: each shipped migration's current checksum must match its committed baseline,
|
|
// computed directly (scope-independent).
|
|
for (const m of migrations) {
|
|
assert.strictEqual(computeChecksum(m), EXPECTED_CHECKSUMS[m.id],
|
|
`'${m.id}' body changed — its checksum drifted from the committed baseline; ` +
|
|
`add a NEW fix-forward migration id instead of editing a shipped migration body, ` +
|
|
`or intentionally update the baseline in EXPECTED_CHECKSUMS`);
|
|
}
|
|
});
|
|
|
|
test('reconciles a drifted applied-migration checksum into install state on apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Set up: one already-applied migration with a stale checksum, one pending migration
|
|
// that will produce an action (so applyInstallerMigrationPlan writes state).
|
|
const alreadyAppliedMigration = migrationRecord({
|
|
id: '2026-05-11-already-applied-with-drift',
|
|
title: 'Already applied with drift',
|
|
description: 'Already applied with drift',
|
|
scopes: ['global'],
|
|
destructive: false,
|
|
plan: () => [],
|
|
});
|
|
const pendingMigration = migrationRecord({
|
|
id: '2026-05-11-pending-to-trigger-apply',
|
|
title: 'Pending migration',
|
|
description: 'Pending migration',
|
|
scopes: ['global'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retiring hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
});
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
// Seed install state: alreadyAppliedMigration recorded with a STALE checksum.
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: alreadyAppliedMigration.id,
|
|
appliedAt: '2026-01-01T00:00:00.000Z',
|
|
journal: null,
|
|
checksum: 'sha256:stale-old',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [alreadyAppliedMigration, pendingMigration],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
// The already-applied migration should appear in checksumDrift.
|
|
const drift = plan.checksumDrift.find((d) => d.id === alreadyAppliedMigration.id);
|
|
assert.ok(drift, 'expected checksumDrift entry for the already-applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:stale-old');
|
|
|
|
// Apply the plan (the pending migration has an action, so this writes state).
|
|
applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
// Re-read install state and assert the stale checksum was reconciled.
|
|
const stateAfter = readInstallState(configDir);
|
|
const reconciledEntry = stateAfter.appliedMigrations.find(
|
|
(entry) => entry.id === alreadyAppliedMigration.id
|
|
);
|
|
assert.ok(reconciledEntry, 'expected the already-applied entry to still be in install state');
|
|
const expectedChecksum = migrationChecksum(alreadyAppliedMigration);
|
|
assert.strictEqual(
|
|
reconciledEntry.checksum,
|
|
expectedChecksum,
|
|
`expected checksum to be reconciled to current value (${expectedChecksum}), not the stale 'sha256:stale-old'`
|
|
);
|
|
assert.notEqual(reconciledEntry.checksum, 'sha256:stale-old',
|
|
'stale checksum must not remain after apply');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3357-codex-legacy-hooks-json-migration (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Regression test for bug #3357.
|
|
*
|
|
* Older Codex installs carried legacy GSD SessionStart commands in hooks.json.
|
|
* Current install keeps the managed SessionStart hook in hooks.json (single
|
|
* representation per layer) and strips stale managed entries before writing
|
|
* exactly one canonical managed command.
|
|
*
|
|
* Bug #1348 (addendum): reconcileCodexHooksJsonEvent must always write the
|
|
* canonical nested { "hooks": { "<Event>": [...] } } shape — never top-level
|
|
* event keys — mirroring reconcileCursorHooksJson.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const installModule = require('../bin/install.js');
|
|
const { readInstallState } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const { install, parseTomlToObject, reconcileCodexHooksJsonEvent } = installModule;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
|
|
function withCodexHome(codexHome, fn) {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now install to $HOME/.agents/skills
|
|
// (os.homedir()-relative, independent of CODEX_HOME). Sandbox HOME (and
|
|
// USERPROFILE) to codexHome so in-process installs never write to the
|
|
// developer/CI machine's real home directory.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = codexHome;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (previousCodexHome == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
if (previousHome == null) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile == null) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
function legacyGsdHook(codexHome) {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js')}"`,
|
|
}],
|
|
};
|
|
}
|
|
|
|
function userHook() {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/user-hook.js"',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function tomlGsdHookCount(codexHome) {
|
|
const parsed = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
|
const sessionStart = parsed.hooks?.SessionStart ?? [];
|
|
return sessionStart
|
|
.flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : [])
|
|
.filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update'))
|
|
.length;
|
|
}
|
|
|
|
describe('#3357 — Codex install removes legacy GSD hooks.json entries', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
|
}
|
|
tmpRoot = createTempDir('gsd-3357-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete installModule.__codexSchemaValidator;
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('rewrites hooks.json to one managed SessionStart hook when file only had legacy managed entry', () => {
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
// #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
'hooks.json must use nested { hooks: { ... } } shape (bug #1348)',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
'hooks.json must NOT have a top-level SessionStart key (bug #1348)',
|
|
);
|
|
const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(managed.length, 1);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('preserves user hooks.json entries while removing the legacy GSD hook', () => {
|
|
const userOwnedSameBasenameHook = {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/gsd-check-update.js"',
|
|
}],
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome), userHook(), userOwnedSameBasenameHook] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
// #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
'hooks.json must use nested { hooks: { ... } } shape (bug #1348)',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
'hooks.json must NOT have a top-level SessionStart key (bug #1348)',
|
|
);
|
|
const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true);
|
|
assert.equal(managed.length, 2);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('restores migrated hooks.json and install state when later Codex validation fails', () => {
|
|
const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2);
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), before);
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'forced migration rollback test',
|
|
});
|
|
|
|
assert.throws(
|
|
() => withCodexHome(codexHome, () => install(true, 'codex')),
|
|
/forced migration rollback test/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before);
|
|
assert.equal(
|
|
readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'),
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #1348 — reconcileCodexHooksJsonEvent must always write canonical nested shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#1348 — reconcileCodexHooksJsonEvent canonical nested shape', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-1348-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// (a) Fresh/absent hooks.json: register → { "hooks": { "SessionStart": [...] } }
|
|
test('(a) fresh/absent hooks.json writes nested { hooks: { SessionStart: [...] } } shape', () => {
|
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist');
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created');
|
|
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
`Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.SessionStart) && hooksJson.hooks.SessionStart.length > 0,
|
|
`Expected hooks.hooks.SessionStart to be a non-empty array; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
});
|
|
|
|
// (b) Legacy migration: seed top-level { "SessionStart": [<user>] }, register →
|
|
// nested hooks.SessionStart contains BOTH migrated user entry AND managed entry
|
|
test('(b) legacy top-level shape: user entries migrate into hooks.SessionStart alongside managed entry', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] };
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [userEntry] }, null, 2),
|
|
);
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
|
|
// Canonical nested shape
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
`Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
|
|
// User entry was migrated under hooks.SessionStart (not dropped)
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
allCommands.includes('node "/Users/alice/my-hook.js"'),
|
|
`User entry must be preserved under hooks.SessionStart; commands: ${JSON.stringify(allCommands)}`,
|
|
);
|
|
|
|
// Managed entry is also present
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, 'Exactly one managed entry must be present under hooks.SessionStart');
|
|
});
|
|
|
|
// (c-i) Dedup: re-registering the same managed command does not duplicate it
|
|
test('(c-i) re-registering managed command produces exactly one managed entry', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, 'Re-register must yield exactly one managed entry');
|
|
});
|
|
|
|
// (c-ii) Removal: user entries remain under hooks, managed entry is gone
|
|
test('(c-ii) removing managed hook leaves user entry under hooks.SessionStart', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] };
|
|
// Seed already-nested file with both user + managed
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
// Now manually seed a user entry into the existing nested file
|
|
const seeded = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
seeded.hooks.SessionStart = [userEntry, ...seeded.hooks.SessionStart];
|
|
fs.writeFileSync(path.join(tmpDir, 'hooks.json'), JSON.stringify(seeded, null, 2));
|
|
|
|
// Remove managed
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
// User entry must still be under hooks.SessionStart
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
allCommands.includes('node "/Users/alice/my-hook.js"'),
|
|
`User entry must remain after managed removal; commands: ${JSON.stringify(allCommands)}`,
|
|
);
|
|
// No managed entry
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 0, 'No managed entry must remain after removal');
|
|
});
|
|
|
|
// (c-iii) Removal from absent file does NOT materialize { "hooks": {} }
|
|
test('(c-iii) removing from absent hooks.json does not write a spurious empty { "hooks": {} }', () => {
|
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
|
assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist');
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null });
|
|
|
|
assert.ok(
|
|
!fs.existsSync(hooksJsonPath),
|
|
'hooks.json must NOT be created when removing from absent file (no spurious { "hooks": {} })',
|
|
);
|
|
});
|
|
|
|
// (d) Mixed nested + top-level shape: { "hooks": { "PreToolUse": [...] }, "SessionStart": [...] }
|
|
// The stray top-level event array must be lifted into hooks and merged; no top-level key survives.
|
|
test('(d) mixed nested + top-level shape: stray top-level event array is lifted and merged', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const existingNestedEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/pre-tool.js"' }] };
|
|
const userTopLevelEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/session-start.js"' }] };
|
|
|
|
// Seed a mixed-shape file: nested PreToolUse AND top-level SessionStart
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks.json'),
|
|
JSON.stringify(
|
|
{
|
|
hooks: { PreToolUse: [existingNestedEntry] },
|
|
SessionStart: [userTopLevelEntry],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
|
|
// No stray top-level SessionStart key
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
|
|
// hooks.SessionStart contains the migrated user entry AND exactly one managed entry
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.SessionStart),
|
|
`hooks.hooks.SessionStart must be an array; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
const sessionCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
sessionCommands.includes('node "/Users/alice/session-start.js"'),
|
|
`Migrated user entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}; full: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
const managedCount = sessionCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, `Exactly one managed entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}`);
|
|
|
|
// hooks.PreToolUse is untouched
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.PreToolUse) && hooksJson.hooks.PreToolUse.length === 1,
|
|
`hooks.hooks.PreToolUse must be preserved with one entry; got: ${JSON.stringify(hooksJson.hooks.PreToolUse)}`,
|
|
);
|
|
const preToolCommands = hooksJson.hooks.PreToolUse
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
preToolCommands.includes('node "/Users/alice/pre-tool.js"'),
|
|
`Existing nested PreToolUse entry must be preserved; commands: ${JSON.stringify(preToolCommands)}`,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3670-cursor-local-install-migration-lock.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3670-cursor-local-install-migration-lock (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Regression tests for issue #3670: --cursor --local install self-deadlocks
|
|
* on gsd-install-migration.lock.
|
|
*
|
|
* Root cause: On Windows, `fs.rmSync(lockPath, { force: true })` in the lock
|
|
* release closure silently swallows EPERM errors that NTFS returns when a
|
|
* recently-closed file descriptor's handle has not yet been fully released by
|
|
* the OS. The lock file is left on disk. The next `runInstallerMigrations`
|
|
* call in the same install() invocation hits EEXIST, spins for
|
|
* DEFAULT_LOCK_TIMEOUT_MS (30 s), then throws "installer migration lock is
|
|
* held". There is also no stale-PID reclamation: if the lock names the
|
|
* current process's PID, the helper should reclaim rather than spin.
|
|
*
|
|
* Windows wall-clock deadlock repro depends on Docker matrix Windows runners.
|
|
* These tests reproduce the failure modes via mock-injected fs faults on any
|
|
* platform (macOS/Linux/Windows). They fail deterministically WITHOUT the fix
|
|
* and pass WITH it.
|
|
*
|
|
* Test plan:
|
|
* T1 (same-process re-entry / stale-PID reclamation — primary regression)
|
|
* Pre-seed the lock file with {pid: process.pid, ...}. Verify that a
|
|
* runInstallerMigrations call reclaims the lock and succeeds rather than
|
|
* spinning 30 s and throwing.
|
|
*
|
|
* T2 (dead-PID reclamation — cross-invocation stale lock)
|
|
* Pre-seed the lock file with a PID known to be dead. Verify that acquire
|
|
* reclaims rather than throws.
|
|
*
|
|
* T3 (silent rmSync swallow / Windows EPERM simulation)
|
|
* Inject a fault that makes fs.rmSync throw EPERM for the lock file only
|
|
* (simulating Windows NTFS delete-pending). Verify that the lock file IS
|
|
* removed by an alternative path (or that the error propagates) — i.e.
|
|
* verify that the fix does not silently leave the lock on disk.
|
|
*
|
|
* T4 (counter-test: normal single acquire/release round-trip still works)
|
|
* No pre-seeded lock. One runInstallerMigrations call. Must succeed and
|
|
* leave no lock file behind.
|
|
*
|
|
* T5 (counter-test: genuinely-held live lock still surfaces an error)
|
|
* Pre-seed lock with a live PID (process.pid) AND simulate a lock that
|
|
* has been "truly acquired" (fd still open). With lockTimeoutMs: 0 and a
|
|
* truly un-reclaimable lock, must still throw with a useful message naming
|
|
* the holder PID. (This guards against over-reclamation.)
|
|
*
|
|
* @see https://github.com/open-gsd/gsd-core/issues/3670
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const {
|
|
INSTALL_MIGRATION_LOCK_NAME,
|
|
runInstallerMigrations,
|
|
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3670-'));
|
|
}
|
|
|
|
function lockPath(dir) {
|
|
return path.join(dir, INSTALL_MIGRATION_LOCK_NAME);
|
|
}
|
|
|
|
function writeLockFile(dir, pid, acquiredAt) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
lockPath(dir),
|
|
JSON.stringify({ pid, acquiredAt: acquiredAt || new Date().toISOString() }) + '\n',
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find a PID that is guaranteed to be dead on this host.
|
|
* We probe a set of high candidate PIDs (far outside the running set) and
|
|
* pick the first one for which process.kill(pid, 0) throws ESRCH.
|
|
* Falls back to 99999 if the probe loop exhausts (extremely unlikely).
|
|
*/
|
|
function findDeadPid() {
|
|
// Avoid process.pid ± small numbers — those could be live siblings.
|
|
for (let candidate = 600000; candidate < 700000; candidate += 1000) {
|
|
try {
|
|
process.kill(candidate, 0);
|
|
// Still alive (or permission denied but exists) — try next
|
|
} catch (err) {
|
|
if (err.code === 'ESRCH') return candidate;
|
|
}
|
|
}
|
|
return 99999; // fallback: extremely unlikely to be a live PID
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T1: Same-process re-entry — stale lock with current process.pid reclaimed
|
|
// ---------------------------------------------------------------------------
|
|
test('T1: reclaims stale lock that names the current process PID (same-process re-entry)', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// Pre-seed lock file with the CURRENT process's PID — exactly what happens
|
|
// on Windows when rmSync swallows EPERM after the first runInstallerMigrations
|
|
// call releases (or fails to release) the lock.
|
|
writeLockFile(configDir, process.pid);
|
|
|
|
// Without the fix: this would spin for lockTimeoutMs then throw.
|
|
// With the fix: detects own PID → reclaims → succeeds.
|
|
// lockTimeoutMs: 200 (fail fast so the test doesn't hang for 30 s without fix)
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result object');
|
|
// Lock file must be removed after the call completes.
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must not remain on disk after successful runInstallerMigrations'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T2: Dead-PID reclamation — cross-invocation stale lock
|
|
// ---------------------------------------------------------------------------
|
|
test('T2: reclaims stale lock whose PID is no longer alive', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
const deadPid = findDeadPid();
|
|
writeLockFile(configDir, deadPid);
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result object');
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must not remain on disk after stale-PID reclamation'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T3: Windows EPERM simulation — unlinkSync failure surfaces (not silently swallowed)
|
|
// ---------------------------------------------------------------------------
|
|
test('T3: lock release does not silently leave lock file on disk when unlink fails (Windows EPERM simulation)', (t) => {
|
|
const configDir = createTempDir();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
|
|
t.after(() => {
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// The fix uses fs.unlinkSync (not fs.rmSync with { force: true }) in the
|
|
// release closure. Inject EPERM on the lock file to simulate the Windows
|
|
// NTFS condition where the recently-closed handle has not been fully
|
|
// released by the OS.
|
|
//
|
|
// The fix's contract: EPERM must NOT be silently swallowed.
|
|
// Either (a) the error propagates as a releaseError, or (b) some alternative
|
|
// deletion path succeeds. Silent-swallow (no error + file still exists) is
|
|
// the failure condition we guard against.
|
|
let unlinkCallCount = 0;
|
|
fs.unlinkSync = function faultInjectUnlinkSync(targetPath) {
|
|
const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME;
|
|
if (isLock) {
|
|
unlinkCallCount++;
|
|
// Simulate Windows EPERM (file handle not fully released by OS)
|
|
const err = Object.assign(
|
|
new Error('EPERM: operation not permitted, unlink ' + targetPath),
|
|
{ code: 'EPERM' }
|
|
);
|
|
throw err;
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
};
|
|
|
|
// With the fix: unlinkSync throws EPERM → releaseError is thrown by the
|
|
// release closure → runInstallerMigrations throws releaseError.
|
|
// With the buggy code (rmSync + force:true): EPERM was swallowed silently,
|
|
// no error thrown, lock file left on disk.
|
|
//
|
|
// Assert: if the call succeeds (no throw), the lock file must be gone.
|
|
// If the call throws, the error message must reference the lock.
|
|
let threw = false;
|
|
let thrownError = null;
|
|
try {
|
|
runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 500,
|
|
});
|
|
} catch (err) {
|
|
threw = true;
|
|
thrownError = err;
|
|
}
|
|
|
|
if (threw) {
|
|
// Acceptable: error surfaced. Verify it's lock-related (not a bug elsewhere).
|
|
assert.match(
|
|
thrownError.message,
|
|
/lock/i,
|
|
'thrown error must reference the lock file'
|
|
);
|
|
} else {
|
|
// If no error was thrown, the lock file must have been removed by some
|
|
// alternative path (not left silently on disk).
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'if unlinkSync EPERM is encountered but no error thrown, lock file must still be removed'
|
|
);
|
|
}
|
|
|
|
// Sanity: the fault injection was actually triggered.
|
|
assert.ok(unlinkCallCount > 0, 'unlinkSync must have been called for the lock file at least once');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T4: Counter-test — normal single acquire/release round-trip still works
|
|
// ---------------------------------------------------------------------------
|
|
test('T4: normal (non-recursive) runInstallerMigrations acquires and releases lock correctly', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// No pre-seeded lock. Standard happy path.
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result');
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must be cleaned up after normal completion'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T5: Counter-test — unreclaimable live lock must surface a bounded error
|
|
// ---------------------------------------------------------------------------
|
|
// This test guards against over-reclamation: if the reclaim-unlink fails
|
|
// (e.g. Windows EPERM on a live open handle), the fix must NOT spin
|
|
// indefinitely — it must fall through to the timeout path and throw.
|
|
//
|
|
// Conditions forced by this test:
|
|
// 1. Lock file contains the CURRENT process.pid (triggers isSameProcess branch).
|
|
// 2. fs.unlinkSync is mocked to throw EPERM for the lock file (reclaim fails).
|
|
// 3. lockTimeoutMs: 200 — timeout must fire within a short wall-clock window.
|
|
//
|
|
// Expected outcome: throws with /installer migration lock is held/ within
|
|
// ~200ms. SUCCESS (no throw) is NOT acceptable here — that would mean the fix
|
|
// over-reclaimed a lock that it couldn't actually remove.
|
|
test('T5: unreclaimable same-PID lock throws bounded error (reclaim-unlink failure falls through to timeout)', (t) => {
|
|
const configDir = createTempDir();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
|
|
t.after(() => {
|
|
mock.restoreAll();
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// Pre-seed lock file with the CURRENT process's PID.
|
|
// This triggers the isSameProcess reclamation path inside acquireInstallerMigrationLock.
|
|
writeLockFile(configDir, process.pid);
|
|
|
|
// Mock unlinkSync to throw EPERM for the lock file only.
|
|
// This simulates Windows NTFS refusing to delete a file with an open handle.
|
|
// With the fix: reclaim-unlink fails → reclaimed=false → falls through to
|
|
// the timeout check → throws "installer migration lock is held" after ≤200ms.
|
|
// Without the fix (original code): unlink throws but continue runs anyway →
|
|
// spins indefinitely, never reaches the timeout check → deadlock.
|
|
mock.method(fs, 'unlinkSync', function faultInjectUnlinkSync(targetPath) {
|
|
const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME;
|
|
if (isLock) {
|
|
const err = Object.assign(
|
|
new Error('EPERM: operation not permitted, unlink ' + targetPath),
|
|
{ code: 'EPERM' }
|
|
);
|
|
throw err;
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
});
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
}),
|
|
(err) => {
|
|
assert.match(err.message, /installer migration lock is held/, 'error must name the held lock');
|
|
return true;
|
|
},
|
|
'must throw "installer migration lock is held" when reclaim-unlink fails — not spin indefinitely'
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/installer-migrations/001-legacy-orphan-files.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:installer-migrations/001-legacy-orphan-files (consolidation epic #1969 B5 #1974)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Characterization tests for the 001-legacy-orphan-files installer migration.
|
|
* Locks the migration metadata shape and plan() logic (managed-pristine and
|
|
* managed-modified classification paths; unmanaged artifacts are skipped).
|
|
*/
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const migration = require('../gsd-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs');
|
|
|
|
describe('migration metadata', () => {
|
|
test('exports a single migration object with required fields', () => {
|
|
assert.equal(typeof migration, 'object');
|
|
assert.equal(migration.id, '2026-05-11-legacy-orphan-files');
|
|
assert.equal(typeof migration.title, 'string');
|
|
assert.equal(typeof migration.description, 'string');
|
|
assert.equal(migration.introducedIn, '1.50.0');
|
|
assert.ok(Array.isArray(migration.scopes));
|
|
assert.ok(migration.scopes.includes('global'));
|
|
assert.ok(migration.scopes.includes('local'));
|
|
assert.strictEqual(migration.destructive, true);
|
|
assert.equal(typeof migration.plan, 'function');
|
|
});
|
|
});
|
|
|
|
describe('migration.plan()', () => {
|
|
function makeClassifier(classification) {
|
|
return { classifyArtifact: () => ({ classification }) };
|
|
}
|
|
|
|
test('returns remove-managed action for managed-pristine artifact', () => {
|
|
const actions = migration.plan(makeClassifier('managed-pristine'));
|
|
assert.equal(actions.length, 2); // two files in LEGACY_ORPHAN_FILES
|
|
for (const action of actions) {
|
|
assert.equal(action.type, 'remove-managed');
|
|
assert.equal(typeof action.relPath, 'string');
|
|
assert.equal(typeof action.reason, 'string');
|
|
assert.equal(typeof action.ownershipEvidence, 'string');
|
|
}
|
|
});
|
|
|
|
test('returns backup-and-remove action for managed-modified artifact', () => {
|
|
const actions = migration.plan(makeClassifier('managed-modified'));
|
|
assert.equal(actions.length, 2);
|
|
for (const action of actions) {
|
|
assert.equal(action.type, 'backup-and-remove');
|
|
}
|
|
});
|
|
|
|
test('returns no actions for unmanaged artifact', () => {
|
|
const actions = migration.plan(makeClassifier('unmanaged'));
|
|
assert.deepStrictEqual(actions, []);
|
|
});
|
|
|
|
test('relPaths match the two legacy orphan hook files', () => {
|
|
const actions = migration.plan(makeClassifier('managed-pristine'));
|
|
const relPaths = actions.map((a) => a.relPath).sort();
|
|
assert.deepStrictEqual(relPaths, [
|
|
'hooks/gsd-notify.sh',
|
|
'hooks/statusline.js',
|
|
]);
|
|
});
|
|
|
|
test('plan handles mixed classifications per file', () => {
|
|
let callCount = 0;
|
|
const ctx = {
|
|
classifyArtifact: (_relPath) => {
|
|
callCount++;
|
|
// first call: managed-pristine; second call: unmanaged
|
|
return { classification: callCount === 1 ? 'managed-pristine' : 'unmanaged' };
|
|
},
|
|
};
|
|
const actions = migration.plan(ctx);
|
|
assert.equal(actions.length, 1);
|
|
assert.equal(actions[0].type, 'remove-managed');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Symlinked managed path: backup must never dereference (#2470 security review)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `fs.copyFileSync` follows symlinks. Before this hardening, a managed path
|
|
// replaced by a link would have had the LINK TARGET's bytes copied into the
|
|
// journal's backup tree — e.g. a `gsd.cjs` symlinked at a private key would
|
|
// land that key's contents under gsd-migration-journal/. Nothing GSD installs
|
|
// is ever a symlink, so the faithful snapshot is the link itself.
|
|
|
|
{
|
|
const { describe, test } = require('node:test');
|
|
describe('symlinked managed path is snapshotted as a link, never dereferenced', () => {
|
|
const piExtensionMigration = require('../gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs');
|
|
const SECRET = 'TOP-SECRET-PRIVATE-KEY-MATERIAL\n';
|
|
|
|
test('backup-and-remove on a symlinked managed file copies the link, not the referent', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
try {
|
|
const secretPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(secretPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(secretPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
|
|
// Manifest records the path as managed with a hash that cannot match the
|
|
// referent -> classification 'managed-modified' -> backup-and-remove.
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
|
|
const backupAction = result.plan.actions.find((a) => a.type === 'backup-and-remove');
|
|
assert.ok(backupAction, 'expected a backup-and-remove action for the modified managed file');
|
|
|
|
// The referent is untouched and still holds its content.
|
|
assert.ok(fs.existsSync(secretPath), 'symlink target must survive');
|
|
assert.equal(fs.readFileSync(secretPath, 'utf8'), SECRET, 'symlink target content must be unchanged');
|
|
|
|
// The link itself is gone from the install tree.
|
|
assert.equal(
|
|
fs.lstatSync(linkPath, { throwIfNoEntry: false }),
|
|
undefined,
|
|
'the symlink at the managed path must be removed',
|
|
);
|
|
|
|
// Nothing anywhere under configDir may contain the referent's bytes.
|
|
const leaked = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isSymbolicLink()) continue; // a link is fine; its content is not copied
|
|
if (entry.isDirectory()) { walk(full); continue; }
|
|
let body;
|
|
try { body = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
if (body.includes('TOP-SECRET')) leaked.push(path.relative(configDir, full));
|
|
}
|
|
};
|
|
walk(configDir);
|
|
assert.deepEqual(leaked, [], `symlink referent content leaked into: ${leaked.join(', ')}`);
|
|
} finally {
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
|
|
test('a regular managed file is still backed up by content (no behavior change)', (t) => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'extensions/gsd.cjs', 'locally patched extension\n');
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
|
|
const backupAction = result.plan.actions.find((a) => a.type === 'backup-and-remove');
|
|
assert.ok(backupAction, 'expected backup-and-remove for the locally patched file');
|
|
|
|
// The PLAN carries backupRelPath: null — the concrete backup location is
|
|
// chosen during apply and recorded in the journal, so read it from there.
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const journalled = journal.actions.find((a) => a.backupRelPath);
|
|
assert.ok(journalled, 'apply must record the backup path in the journal for the user');
|
|
const backupPath = path.join(configDir, journalled.backupRelPath);
|
|
assert.equal(
|
|
fs.readFileSync(backupPath, 'utf8'),
|
|
'locally patched extension\n',
|
|
'a real file must still be backed up by content so the user can recover it',
|
|
);
|
|
assert.ok(!fs.existsSync(path.join(configDir, 'extensions', 'gsd.cjs')));
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
// In-flight failure recovery: when a later step of the SAME apply() attempt
|
|
// throws, the catch block replays the rollback snapshots it already took.
|
|
// Those snapshots are themselves symlinks, so a raw copy there dereferences
|
|
// and writes the referent's bytes back to the LIVE install path — worse than
|
|
// the journal-tree leak, because it is user-visible at a predictable path.
|
|
//
|
|
// The failure is injected by monkeypatching fs.rmSync (restored in finally)
|
|
// rather than by chmod/permission tricks: deterministic, root- and
|
|
// OS-independent. The delete of the managed path is allowed to SUCCEED and
|
|
// then throws once, modelling a later step failing after the delete. That
|
|
// ordering is load-bearing: if the live path still existed, the pre-fix
|
|
// copyFileSync would hit a same-file collision and throw instead of leaking,
|
|
// and this test would pass against the very bug it exists to catch.
|
|
test('apply failure after a symlinked snapshot does not leak the referent into the live tree', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
const realRmSync = fs.rmSync;
|
|
try {
|
|
const secretPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(secretPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(secretPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
let fired = false;
|
|
fs.rmSync = function patched(target, options) {
|
|
const result = realRmSync.call(fs, target, options);
|
|
if (!fired && path.resolve(String(target)) === path.resolve(linkPath)) {
|
|
fired = true;
|
|
throw new Error('injected post-delete failure');
|
|
}
|
|
return result;
|
|
};
|
|
|
|
assert.throws(() => runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
}), /injected post-delete failure/, 'the injected failure must propagate, not be swallowed');
|
|
|
|
fs.rmSync = realRmSync;
|
|
|
|
// The referent is untouched...
|
|
assert.ok(fs.existsSync(secretPath));
|
|
assert.equal(fs.readFileSync(secretPath, 'utf8'), SECRET);
|
|
|
|
// ...the managed path is restored as a LINK, not a dereferenced copy...
|
|
const restored = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
|
assert.ok(restored, 'failure recovery must restore the managed path');
|
|
assert.ok(
|
|
restored.isSymbolicLink(),
|
|
'restored path must be a symlink — a regular file here means the referent was dereferenced into the live tree',
|
|
);
|
|
|
|
// ...and its bytes appear nowhere under the install tree.
|
|
const leaked = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) { walk(full); continue; }
|
|
let body;
|
|
try { body = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
if (body.includes('TOP-SECRET')) leaked.push(path.relative(configDir, full));
|
|
}
|
|
};
|
|
walk(configDir);
|
|
assert.deepEqual(leaked, [], `referent content leaked into: ${leaked.join(', ')}`);
|
|
} finally {
|
|
fs.rmSync = realRmSync;
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
|
|
test('rollback() restores a symlinked managed path as a link, not a dereferenced copy', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
try {
|
|
const targetPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(targetPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(targetPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
assert.equal(fs.lstatSync(linkPath, { throwIfNoEntry: false }), undefined, 'link removed by apply');
|
|
|
|
result.rollback();
|
|
|
|
const restored = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
|
assert.ok(restored, 'rollback must restore the managed path');
|
|
assert.ok(restored.isSymbolicLink(), 'restored path must be a symlink, not a dereferenced copy');
|
|
assert.equal(fs.readlinkSync(linkPath), targetPath, 'restored link must point at the original target');
|
|
assert.equal(fs.readFileSync(targetPath, 'utf8'), SECRET, 'target content must be untouched throughout');
|
|
} finally {
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
});
|
|
}
|