fix(extensions): always prefer vendored @fusion/pi-claude-cli over external installs
When users have an external pi-claude-cli (e.g. a global `npm install -g pi-claude-cli`, or `npm:pi-claude-cli` in ~/.pi/agent/settings.json packages), pi's extension discovery loaded the upstream copy and shadowed our fork. The upstream has a once-and-lock MCP-config bug that throws "Extension runtime not initialized" during early streamSimple calls and never recovers. Adds reconcileClaudeCliPaths in @fusion/core, used by both the daemon's extension assembly and the engine's per-session registerExtensionProviders, to drop any path with a `pi-claude-cli` segment that isn't our vendored fork and prepend the vendored path. Engine resolves the fork via require.resolve and gracefully no-ops when it isn't reachable (e.g. embedded standalone usage). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
GlobalSettingsStore,
|
||||
resolveGlobalDir,
|
||||
getEnabledPiExtensionPaths,
|
||||
reconcileClaudeCliPaths,
|
||||
} from "@fusion/core";
|
||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
||||
@@ -411,12 +412,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}
|
||||
})();
|
||||
|
||||
// Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any
|
||||
// external `pi-claude-cli` install. Drops shadowing externals (e.g. a
|
||||
// global `npm install -g pi-claude-cli`) so the upstream's once-and-lock
|
||||
// MCP-config bug can't poison sessions.
|
||||
const reconciledExtensionPaths = reconcileClaudeCliPaths(
|
||||
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||
claudeCliPaths[0] ?? null,
|
||||
);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[
|
||||
...getEnabledPiExtensionPaths(cwd),
|
||||
...packageExtensionPaths,
|
||||
...claudeCliPaths,
|
||||
],
|
||||
reconciledExtensionPaths,
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { reconcileClaudeCliPaths } from "../pi-extensions.js";
|
||||
|
||||
const VENDORED = "/repo/packages/pi-claude-cli/index.ts";
|
||||
const GLOBAL_NPM = "/opt/homebrew/lib/node_modules/pi-claude-cli/index.ts";
|
||||
const PI_AGENT = "/Users/u/.pi/agent/extensions/pi-claude-cli/index.ts";
|
||||
const UNRELATED = "/Users/u/.pi/agent/extensions/quota.ts";
|
||||
|
||||
describe("reconcileClaudeCliPaths", () => {
|
||||
it("returns input unchanged when no vendored path is supplied", () => {
|
||||
const input = [GLOBAL_NPM, UNRELATED];
|
||||
expect(reconcileClaudeCliPaths(input, null)).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops a globally-installed pi-claude-cli and prepends the vendored path", () => {
|
||||
const result = reconcileClaudeCliPaths([GLOBAL_NPM, UNRELATED], VENDORED);
|
||||
expect(result).toEqual([VENDORED, UNRELATED]);
|
||||
});
|
||||
|
||||
it("drops pi-claude-cli installed under .pi/agent/extensions/", () => {
|
||||
const result = reconcileClaudeCliPaths([PI_AGENT, UNRELATED], VENDORED);
|
||||
expect(result).toEqual([VENDORED, UNRELATED]);
|
||||
});
|
||||
|
||||
it("keeps the vendored path exactly once even if it appears in input", () => {
|
||||
const result = reconcileClaudeCliPaths(
|
||||
[VENDORED, GLOBAL_NPM, UNRELATED],
|
||||
VENDORED,
|
||||
);
|
||||
expect(result).toEqual([VENDORED, UNRELATED]);
|
||||
});
|
||||
|
||||
it("preserves the relative order of unrelated extension paths", () => {
|
||||
const a = "/ext/a.ts";
|
||||
const b = "/ext/b.ts";
|
||||
const c = "/ext/c.ts";
|
||||
const result = reconcileClaudeCliPaths([a, GLOBAL_NPM, b, c], VENDORED);
|
||||
expect(result).toEqual([VENDORED, a, b, c]);
|
||||
});
|
||||
|
||||
it("does not mistake substrings of pi-claude-cli for the package", () => {
|
||||
const looksLike = "/ext/some-pi-claude-cli-helper/index.ts";
|
||||
const result = reconcileClaudeCliPaths([looksLike], VENDORED);
|
||||
expect(result).toEqual([VENDORED, looksLike]);
|
||||
});
|
||||
|
||||
it("matches case-insensitively (e.g. on macOS-cased filesystems)", () => {
|
||||
const upper = "/opt/homebrew/lib/node_modules/PI-CLAUDE-CLI/index.ts";
|
||||
const result = reconcileClaudeCliPaths([upper], VENDORED);
|
||||
expect(result).toEqual([VENDORED]);
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export { ArchiveDatabase } from "./archive-db.js";
|
||||
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
||||
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
|
||||
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
|
||||
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
||||
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
||||
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
|
||||
@@ -226,6 +226,56 @@ export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[],
|
||||
return discoverPiExtensions(cwd, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: does this extension path look like an external (non-Fusion)
|
||||
* `pi-claude-cli` install? We match any path with a directory segment named
|
||||
* exactly `pi-claude-cli`, except for the explicit vendored path that callers
|
||||
* pass in (which always wins).
|
||||
*
|
||||
* Example matches: `/opt/homebrew/lib/node_modules/pi-claude-cli/index.ts`,
|
||||
* `~/.pi/agent/extensions/pi-claude-cli/index.ts`.
|
||||
*/
|
||||
function isExternalClaudeCliPath(p: string, vendoredPath: string | null): boolean {
|
||||
if (vendoredPath && p === vendoredPath) return false;
|
||||
// Match a path segment "pi-claude-cli" delimited by either separator.
|
||||
return /(^|[/\\])pi-claude-cli([/\\]|$)/i.test(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the assembled pi-extension load list so Fusion's vendored
|
||||
* `@fusion/pi-claude-cli` always wins over any externally-installed
|
||||
* `pi-claude-cli` (e.g. a stale `npm install -g pi-claude-cli` left in
|
||||
* `/opt/homebrew/lib/node_modules`, or `npm:pi-claude-cli` in agent
|
||||
* settings).
|
||||
*
|
||||
* Two motivating scenarios:
|
||||
* 1. The published upstream package has a once-and-lock MCP-config bug that
|
||||
* causes "Extension runtime not initialized" during early streamSimple
|
||||
* calls; our fork fixes it via context.tools-driven regeneration.
|
||||
* 2. Side-by-side loading of two extensions that register the same
|
||||
* provider name (`pi-claude-cli`) produces unpredictable winners
|
||||
* depending on load order.
|
||||
*
|
||||
* Behaviour:
|
||||
* - When `vendoredPath` is null (caller couldn't find the fork — typically
|
||||
* because Fusion isn't running): return the input unchanged.
|
||||
* - When `vendoredPath` is set: drop every external pi-claude-cli path and
|
||||
* ensure the vendored path is loaded first.
|
||||
*/
|
||||
export function reconcileClaudeCliPaths(
|
||||
paths: readonly string[],
|
||||
vendoredPath: string | null,
|
||||
): string[] {
|
||||
if (!vendoredPath) {
|
||||
return [...paths];
|
||||
}
|
||||
const filtered = paths.filter((p) => !isExternalClaudeCliPath(p, vendoredPath));
|
||||
if (!filtered.includes(vendoredPath)) {
|
||||
return [vendoredPath, ...filtered];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function formatPiExtensionSource(source: PiExtensionSource, extensionPath: string, cwd: string, home?: string): string {
|
||||
const homeDir = getHomeDir(home);
|
||||
const projectRoot = resolvePiExtensionProjectRoot(cwd);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/pi-claude-cli": "workspace:*",
|
||||
"@mariozechner/pi-ai": "^0.70.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.0",
|
||||
"typebox": "^1.0.0",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { createRequire } from "node:module";
|
||||
import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type AgentSession,
|
||||
type ToolDefinition,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core";
|
||||
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core";
|
||||
import {
|
||||
resolveSessionSkills,
|
||||
createSkillsOverrideFromSelection,
|
||||
@@ -674,6 +675,33 @@ function getPackageManagerAgentDir(): string {
|
||||
return existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to Fusion's vendored `@fusion/pi-claude-cli`
|
||||
* extension entry. Used by `registerExtensionProviders` to ensure the fork
|
||||
* always wins over any externally-installed `pi-claude-cli`.
|
||||
*
|
||||
* Returns null when the vendored package isn't available (e.g. someone
|
||||
* embedded `@fusion/engine` standalone without bundling the fork) — callers
|
||||
* should treat that as "no override needed, leave external paths alone".
|
||||
*/
|
||||
function resolveVendoredClaudeCliEntry(): string | null {
|
||||
try {
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const pkgJsonPath = require_.resolve("@fusion/pi-claude-cli/package.json");
|
||||
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as {
|
||||
pi?: { extensions?: unknown };
|
||||
};
|
||||
const extensions = pkgJson.pi?.extensions;
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const entry = extensions[0];
|
||||
if (typeof entry !== "string" || entry.length === 0) return null;
|
||||
const path = resolve(dirname(pkgJsonPath), entry);
|
||||
return existsSync(path) ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise<void> {
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
@@ -687,8 +715,19 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
|
||||
.filter((resource) => resource.enabled)
|
||||
.map((resource) => resource.path);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
// Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any external
|
||||
// `pi-claude-cli` install (e.g. a global `npm install -g pi-claude-cli`,
|
||||
// or `npm:pi-claude-cli` in agent settings). Upstream has known timing
|
||||
// and once-and-lock MCP-config bugs that we fix in the fork; loading both
|
||||
// also produces unpredictable provider-registration winners.
|
||||
const vendoredClaudeCli = resolveVendoredClaudeCliEntry();
|
||||
const reconciledPaths = reconcileClaudeCliPaths(
|
||||
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
|
||||
vendoredClaudeCli,
|
||||
);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
reconciledPaths,
|
||||
cwd,
|
||||
join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user