Files
msd-core/tests/installer-migration-pi-extension-ext.test.cjs
Tom Boucher 909a3b180b fix(#2470): install pi's extension as gsd.js so pi actually discovers it (#2478)
* 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>
2026-07-21 08:26:47 -04:00

232 lines
8.4 KiB
JavaScript

'use strict';
/**
* TDD tests for installer migration 006:
* 2026-07-20-pi-extension-cjs-to-js (#2470)
*
* #2470 renamed pi's installed native extension from `extensions/gsd.cjs` to
* `extensions/gsd.js`, because pi's own auto-discovery filter
* (`isExtensionFile()` in @earendil-works/pi-coding-agent) accepts only `.ts`
* and `.js` and silently skips everything else. Installs made before that fix
* still carry the stale, permanently-inert `extensions/gsd.cjs`; the installer
* writes the new `.js` alongside it and would otherwise orphan the old file
* forever (it drops out of the manifest, so uninstall never removes it).
*
* This migration retires the stale copy. Coverage follows the matrix in
* docs/installer-migrations.md#authoring-workflow:
* 1. metadata / authoring-guard conformance
* 2. stale file absent -> empty plan (idempotent, fresh installs)
* 3. stale file managed-pristine -> remove-managed
* 4. stale file managed-modified -> backup-and-remove (never silent delete)
* 5. stale file unknown -> NO action (never remove unowned files)
* 6. the replacement gsd.js and neighbouring user files are never touched
* 7. runtime scoping: pi only
*/
const { describe, test } = 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 migration = require('../gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs');
const {
classifyArtifact: realClassifyArtifact,
readInstallManifest,
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
const STALE_REL = 'extensions/gsd.cjs';
const CURRENT_REL = 'extensions/gsd.js';
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migration-006-test-'));
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in migration test; no helpers import available
fs.rmSync(dir, { recursive: true, force: true });
}
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.7.0',
timestamp: '2026-07-20T00:00:00.000Z',
mode: 'full',
files,
},
null,
2,
),
'utf8',
);
}
function makePlanCtx(configDir) {
const manifest = readInstallManifest(configDir);
return {
configDir,
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
};
}
/** sha256 hash in the manifest's own format, so a file reads as pristine. */
function hashOf(root, relPath) {
const crypto = require('node:crypto');
return crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(root, relPath)))
.digest('hex');
}
// ---------------------------------------------------------------------------
// 1. Metadata
// ---------------------------------------------------------------------------
describe('migration 006 metadata', () => {
test('exports a single migration object with the required authoring fields', () => {
assert.equal(typeof migration, 'object');
assert.equal(typeof migration.id, 'string');
assert.ok(migration.id.length > 0, 'id must be non-empty');
assert.equal(typeof migration.title, 'string');
assert.equal(typeof migration.description, 'string');
assert.equal(typeof migration.introducedIn, 'string');
assert.ok(Array.isArray(migration.scopes), 'scopes must be an array');
assert.ok(migration.scopes.includes('global'), 'scopes must include global');
assert.ok(migration.scopes.includes('local'), 'scopes must include local');
assert.strictEqual(migration.destructive, true);
assert.equal(typeof migration.plan, 'function');
});
test('is scoped to pi only (no other runtime installs this artifact)', () => {
assert.ok(Array.isArray(migration.runtimes), 'runtimes must be an explicit array');
assert.deepEqual(migration.runtimes, ['pi']);
});
test('id carries the expected date prefix and names the retired artifact', () => {
assert.ok(
migration.id.startsWith('2026-07-20-'),
`id should start with the date prefix, got: ${migration.id}`,
);
assert.match(migration.id, /pi-extension/);
});
});
// ---------------------------------------------------------------------------
// 2-5. plan() behaviour by classification
// ---------------------------------------------------------------------------
describe('migration 006 plan()', () => {
test('emits no actions when the stale extension is absent (fresh install, idempotent)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, CURRENT_REL, '// current extension\n');
writeManifest(dir, { [CURRENT_REL]: hashOf(dir, CURRENT_REL) });
const actions = migration.plan(makePlanCtx(dir));
assert.deepEqual(actions, [], 'no stale file -> no actions');
});
test('emits remove-managed for a pristine manifest-managed stale extension', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale pre-#2470 extension\n');
writeManifest(dir, { [STALE_REL]: hashOf(dir, STALE_REL) });
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1, `expected exactly one action, got ${JSON.stringify(actions)}`);
assert.equal(actions[0].type, 'remove-managed');
assert.equal(actions[0].relPath, STALE_REL);
assert.ok(
typeof actions[0].ownershipEvidence === 'string' && actions[0].ownershipEvidence.length > 0,
'destructive actions require ownershipEvidence (authoring guard)',
);
assert.ok(
typeof actions[0].reason === 'string' && actions[0].reason.length > 0,
'action must carry a human-readable reason for dry-run output',
);
});
test('emits backup-and-remove when the user locally modified the stale extension', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale pre-#2470 extension\n');
// Manifest records a DIFFERENT hash -> managed-modified.
writeManifest(dir, { [STALE_REL]: 'a'.repeat(64) });
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1);
assert.equal(
actions[0].type,
'backup-and-remove',
'a locally patched managed file must be backed up, never silently deleted',
);
assert.equal(actions[0].relPath, STALE_REL);
assert.ok(actions[0].ownershipEvidence);
});
test('emits NO action for an unknown (non-manifest) gsd.cjs — never remove unowned files', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
// File present but absent from the manifest -> classification 'unknown'.
writeFile(dir, STALE_REL, '// hand-placed by the user\n');
writeManifest(dir, {});
const actions = migration.plan(makePlanCtx(dir));
assert.deepEqual(
actions,
[],
'unknown files are preserved (docs/installer-migrations.md#ownership)',
);
});
test('never targets the replacement extension or neighbouring user files', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale\n');
writeFile(dir, CURRENT_REL, '// current\n');
writeFile(dir, 'extensions/my-own-extension.js', '// user-authored\n');
writeManifest(dir, {
[STALE_REL]: hashOf(dir, STALE_REL),
[CURRENT_REL]: hashOf(dir, CURRENT_REL),
});
const actions = migration.plan(makePlanCtx(dir));
const targeted = actions.map((a) => a.relPath);
assert.deepEqual(targeted, [STALE_REL]);
assert.ok(!targeted.includes(CURRENT_REL), 'must not remove the replacement extension');
assert.ok(
!targeted.includes('extensions/my-own-extension.js'),
"must not touch a user's own extension sitting in the same directory",
);
});
test('plan() does not mutate disk (planning is pure)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, STALE_REL, '// stale\n');
writeManifest(dir, { [STALE_REL]: hashOf(dir, STALE_REL) });
migration.plan(makePlanCtx(dir));
assert.ok(
fs.existsSync(path.join(dir, STALE_REL)),
'plan() must not remove anything — the executor owns mutation',
);
});
});