* 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>
223 lines
11 KiB
JavaScript
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`,
|
|
);
|
|
});
|