Files
msd-core/tests/pi-upgrades.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

223 lines
11 KiB
JavaScript

'use strict';
/**
* pi upgrades — ADR-1239 Phase D / #2102 Stage 2 (EoS/pi).
*
* Mirrors tests/opencode-imperative-reference.test.cjs's structure (Host-
* Integration axes classification/negotiation + the Context7-verified
* upgrades) for pi's three additive upgrades:
* 1. EXTENSION_EVENT_SURFACES.pi — the full ~30-event ExtensionAPI surface
* (was a single-event ['tool_call'] placeholder).
* 2. Event bindings — pi/gsd.cjs actually binds session_start,
* before_agent_start, session_before_compact (+ tool_call) via pi.on(),
* not just declaring the surface in host-integration.cts.
* 3. Active-model steering — before_provider_request resolves GSD's
* tier→model via the model-catalog's pi entries (populated this stage)
* and returns a bare anthropic model id pi's built-in models accept;
* fails open (returns undefined) when resolution comes back null.
*
* Plus the command-surface completions (getArgumentCompletions) and the
* standard fail-closed negotiation guarantee.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
extensionEventSurfaceFor,
negotiateHostCapabilities,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const { RUNTIME_PROFILE_MAP } = require('../gsd-core/bin/lib/model-catalog.cjs');
const gsdPiExtension = require('../pi/gsd.cjs');
const { _internals } = require('../pi/gsd.cjs');
const PI_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'pi', 'capability.json'), 'utf8'),
);
const PI_AXES = PI_CAP.runtime.hostIntegration;
function mockPi() {
const recorded = { commands: {}, tools: {}, events: {} };
return {
registerCommand(name, def) { recorded.commands[name] = def; },
registerTool(def) { if (def && def.name) recorded.tools[def.name] = def; },
registerProvider() {
throw new Error('gsdPiExtension must NOT call registerProvider — GSD steers pi\'s existing anthropic models, it does not add a new provider');
},
on(event, handler) { (recorded.events[event] = recorded.events[event] || []).push(handler); },
_recorded: recorded,
};
}
// -- (1) EXTENSION_EVENT_SURFACES.pi has all 30 events -----------------------
const EXPECTED_PI_EVENTS = [
'session_start', 'project_trust', 'resources_discover', 'input',
'before_agent_start', 'agent_start', 'message_start', 'message_update',
'message_end', 'turn_start', 'context', 'before_provider_request',
'after_provider_response', 'tool_execution_start', 'tool_execution_update',
'tool_execution_end', 'tool_call', 'tool_result', 'turn_end', 'agent_end',
'session_before_switch', 'session_shutdown', 'session_before_fork',
'session_info_changed', 'session_before_compact', 'session_compact',
'session_before_tree', 'session_tree', 'thinking_level_select', 'model_select',
];
test('pi extension-event surface declares all 30 documented ExtensionAPI events (#2102)', () => {
const surface = extensionEventSurfaceFor('pi');
assert.ok(surface, 'pi is a consumed extensionEvents dialect');
assert.equal(surface.length, 30, `expected exactly 30 events, got ${surface.length}`);
for (const ev of EXPECTED_PI_EVENTS) {
assert.ok(surface.includes(ev), `expected pi extension-event surface to include "${ev}"`);
}
assert.deepEqual([...surface].sort(), [...EXPECTED_PI_EVENTS].sort());
});
// -- (2) the binding actually binds session_start/before_agent_start/ -------
// session_before_compact (not just declared in host-integration.cts)
test('gsdPiExtension binds session_start, before_agent_start, session_before_compact, tool_call, before_provider_request', () => {
const pi = mockPi();
gsdPiExtension(pi);
for (const ev of ['session_start', 'before_agent_start', 'session_before_compact', 'tool_call', 'before_provider_request']) {
assert.ok(Array.isArray(pi._recorded.events[ev]) && pi._recorded.events[ev].length > 0,
`expected gsdPiExtension to bind pi.on("${ev}", ...)`);
}
});
test('gsdPiExtension does NOT call registerProvider (GSD steers pi\'s existing anthropic models, not a new provider)', () => {
const pi = mockPi();
// If gsdPiExtension called registerProvider, mockPi's registerProvider throws.
assert.doesNotThrow(() => gsdPiExtension(pi));
});
// Finding #3 (adversarial review): the tests below previously only exercised
// buildBeforeProviderRequestHandler() directly (the builder), never the
// handler ACTUALLY REGISTERED via pi.on('before_provider_request', ...) in
// gsdPiExtension. This ties the bound handler (default tier = 'sonnet') to
// the real model-catalog steering end-to-end.
test('the ACTUALLY-REGISTERED before_provider_request handler steers to the default-tier model-catalog pi id', async () => {
const pi = mockPi();
gsdPiExtension(pi);
const boundHandler = pi._recorded.events['before_provider_request'][0];
assert.equal(typeof boundHandler, 'function');
const out = await boundHandler({ payload: {} }, { cwd: __dirname });
assert.ok(out, 'expected a modified payload, not undefined');
assert.equal(out.model, RUNTIME_PROFILE_MAP.pi.sonnet.model, 'the bound handler steers to the default (sonnet) tier\'s model-catalog pi id');
assert.equal(out.model, 'claude-sonnet-5');
});
// -- (3) before_provider_request: active-model steering ----------------------
test('before_provider_request resolves a tier that maps to a model → returns a payload with the bare anthropic model id (model-catalog pi ids)', async () => {
const handler = _internals.buildBeforeProviderRequestHandler({ tier: 'sonnet' });
const result = await handler({ payload: { existing: 'field' } }, { cwd: __dirname });
assert.ok(result, 'expected a modified payload, not undefined');
assert.equal(result.existing, 'field', 'original payload fields are preserved');
assert.equal(result.model, RUNTIME_PROFILE_MAP.pi.sonnet.model, 'model id matches the model-catalog pi entry');
assert.equal(result.model, 'claude-sonnet-5');
});
test('before_provider_request resolves opus/haiku tiers to their model-catalog pi ids too', async () => {
const opusHandler = _internals.buildBeforeProviderRequestHandler({ tier: 'opus' });
const opusResult = await opusHandler({ payload: {} }, { cwd: __dirname });
assert.equal(opusResult.model, RUNTIME_PROFILE_MAP.pi.opus.model);
const haikuHandler = _internals.buildBeforeProviderRequestHandler({ tier: 'haiku' });
const haikuResult = await haikuHandler({ payload: {} }, { cwd: __dirname });
assert.equal(haikuResult.model, RUNTIME_PROFILE_MAP.pi.haiku.model);
});
test('before_provider_request given a tier that resolves to null returns undefined (fail-open, never a wrong/empty id)', async () => {
const handler = _internals.buildBeforeProviderRequestHandler({ tier: 'not-a-real-tier-8675309' });
const result = await handler({ payload: { existing: 'field' } }, { cwd: __dirname });
assert.equal(result, undefined);
});
// -- getArgumentCompletions returns family suggestions -----------------------
test('getArgumentCompletions filters PI_COMMAND_FAMILIES by prefix and returns null when empty', () => {
const matches = _internals.getArgumentCompletions('mi');
assert.ok(Array.isArray(matches) && matches.length > 0);
assert.ok(matches.some((m) => m.value === 'milestone'));
for (const m of matches) {
assert.equal(typeof m.value, 'string');
assert.equal(typeof m.label, 'string');
}
const all = _internals.getArgumentCompletions('');
assert.ok(Array.isArray(all) && all.length > 0);
assert.deepEqual(all.map((m) => m.value), [..._internals.PI_COMMAND_FAMILIES]);
const none = _internals.getArgumentCompletions('zzz-no-such-family-8675309');
assert.equal(none, null);
});
// -- fail-closed negotiation for pi -------------------------------------------
test('negotiateHostCapabilities never throws for pi, even on an undeclared/corrupted axis', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...PI_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...PI_AXES, embeddingMode: 'future-unknown-axis-value' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...PI_AXES, dispatch: undefined }));
});
test('pi axes negotiate modelMode:"active" (the active-model steering axis)', () => {
const result = negotiateHostCapabilities(PI_AXES);
assert.equal(result.effective.modelMode, 'active');
});
// -- native-extension auto-discovery contract (#2470) -------------------------
//
// pi auto-discovers extensions by scanning <agentDir>/extensions/ and keeping
// only names its `isExtensionFile()` predicate accepts:
//
// function isExtensionFile(name) {
// return name.endsWith(".ts") || name.endsWith(".js");
// }
//
// (@earendil-works/pi-coding-agent, packages/coding-agent/src/core/extensions/
// loader.ts — verified upstream 2026-07-20.) A dest filename outside that set
// is skipped SILENTLY: no /gsd command, no error, no log line. pi loads the
// accepted file through jiti, which handles CommonJS and ESM alike, so the
// extension's module format is irrelevant to discovery — only the suffix is.
//
// These assertions deliberately encode pi's PREDICATE rather than the literal
// filename, so they keep protecting the contract if the extension is ever
// renamed again, and they state the reason the rename mattered.
/** pi's upstream discovery predicate, mirrored verbatim. */
function piIsExtensionFile(name) {
return name.endsWith('.ts') || name.endsWith('.js');
}
test('pi capability declares a native-extension dest filename pi will auto-discover (#2470)', () => {
const np = PI_CAP.runtime.hostBehaviors.nativePlugin;
assert.ok(np && np.file, 'pi must declare hostBehaviors.nativePlugin.file');
assert.ok(
piIsExtensionFile(np.file),
`pi's installed extension "${np.file}" must end in .ts or .js — pi's isExtensionFile() ` +
'auto-discovery filter silently skips every other suffix, so /gsd never registers (#2470)',
);
});
test('pi native-extension source stays CommonJS-explicit while the dest satisfies pi (#2470)', () => {
const np = PI_CAP.runtime.hostBehaviors.nativePlugin;
// The in-repo source keeps its .cjs suffix on purpose: tests require() it
// directly and .cjs is unambiguous CommonJS regardless of any future
// package.json "type" flip. Only the INSTALLED name must satisfy pi, and
// jiti parses the copied file by content, not by suffix.
assert.ok(
np.source.endsWith('.cjs'),
`pi's in-repo extension source should stay .cjs (explicit CommonJS), got "${np.source}"`,
);
assert.ok(
fs.existsSync(path.join(__dirname, '..', np.source)),
`pi's declared nativePlugin.source "${np.source}" must exist in the repo`,
);
});