fix(FN-1952): restore pi auth and extension loading

This commit is contained in:
gsxdsm
2026-04-16 21:42:16 -07:00
parent 22e411a82b
commit 32a92e961e
30 changed files with 644 additions and 72 deletions

View File

@@ -459,6 +459,7 @@ vi.mock("@fusion/core", () => ({
PluginLoader: mocks.pluginLoaderCtor,
GlobalSettingsStore: vi.fn().mockImplementation(() => mocks.globalSettingsStoreInstance),
resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.fusion"),
getEnabledPiExtensionPaths: vi.fn(() => []),
DaemonTokenManager: vi.fn().mockImplementation(() => ({
getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)),
generateToken: vi.fn().mockImplementation(() => {

View File

@@ -150,6 +150,7 @@ vi.mock("@fusion/core", () => ({
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
})),
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn().mockReturnValue(undefined),
syncInsightExtractionAutomation: mockSyncInsightExtraction,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
@@ -479,7 +480,11 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
await runDashboard(0, {});
expect(mockDiscoverAndLoadExtensions).toHaveBeenCalledWith([], expect.any(String), undefined);
expect(mockDiscoverAndLoadExtensions).toHaveBeenCalledWith(
[],
expect.any(String),
expect.stringContaining(".fusion/disabled-auto-extension-discovery"),
);
expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith(
"custom-anthropic",
expect.objectContaining({ models: [{ id: "claude-sonnet-4-5" }] }),
@@ -786,8 +791,7 @@ describe("runDashboard — multi-project cwd/default engine resolution", () => {
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
// Verify CentralCore.getProjectByPath was called with cwd
const centralCore = centralInstances[0];
expect(centralCore.getProjectByPath).toHaveBeenCalled();
expect(centralInstances.some((instance) => instance.getProjectByPath.mock.calls.length > 0)).toBe(true);
// Verify createServer received an engine (the cwd/default engine)
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];

View File

@@ -495,6 +495,7 @@ vi.mock("@fusion/core", () => ({
CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor,
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",

View File

@@ -1,8 +1,17 @@
import { homedir } from "node:os";
import { existsSync } from "node:fs";
import { join } from "node:path";
export function getFusionAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".fusion", "agent");
}
export function getLegacyAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".pi", "agent");
}
export function getFusionAuthPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".fusion", "agent", "auth.json");
return join(getFusionAgentDir(home), "auth.json");
}
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
@@ -11,3 +20,36 @@ export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPR
join(home, ".pi", "auth.json"),
];
}
export function getFusionModelsPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(getFusionAgentDir(home), "models.json");
}
export function getLegacyModelsPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
export function getPackageManagerAgentDir(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
const fusionAgentDir = getFusionAgentDir(home);
if (
existsSync(join(fusionAgentDir, "settings.json")) ||
existsSync(join(fusionAgentDir, "extensions"))
) {
return fusionAgentDir;
}
const legacyAgentDir = getLegacyAgentDir(home);
return existsSync(legacyAgentDir) ? legacyAgentDir : fusionAgentDir;
}

View File

@@ -9,6 +9,7 @@
*/
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
@@ -19,6 +20,7 @@ import {
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
@@ -28,7 +30,6 @@ import {
DefaultPackageManager,
ModelRegistry,
discoverAndLoadExtensions,
getAgentDir,
createExtensionRuntime,
} from "@mariozechner/pi-coding-agent";
import {
@@ -38,12 +39,21 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
let daemonDbHealthCheck: (() => boolean) | null = null;
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
/**
* Format bytes to human-readable string
*/
@@ -198,7 +208,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
const selectedHost = opts.host ?? "0.0.0.0";
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
let ntfyProjectId: string | undefined;
@@ -328,13 +338,13 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
try {
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -346,9 +356,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
.map((r) => r.path);
const extensionsResult = await discoverAndLoadExtensions(
packageExtensionPaths,
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
undefined,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {

View File

@@ -156,6 +156,7 @@ vi.mock("@fusion/core", () => ({
emit: emitter.emit.bind(emitter),
};
}),
getEnabledPiExtensionPaths: vi.fn(() => []),
getTaskMergeBlocker: vi.fn((task: any) => {
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
if (task.paused) return "task is paused";

View File

@@ -1,8 +1,9 @@
import type { AddressInfo } from "node:net";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker } from "@fusion/core";
import { join } from "node:path";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
processPullRequestMergeTask,
@@ -10,7 +11,8 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -183,6 +185,14 @@ function setDiagnosticStoreListenerCheck(check: () => Record<string, number>): v
diagnosticStoreListenerCheck = check;
}
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
ensureProcessDiagnostics();
@@ -199,7 +209,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
throw err;
}
}
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
const store = new TaskStore(cwd);
await store.init();
await store.watch();
@@ -365,7 +375,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
@@ -374,7 +384,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Resolve extension paths from pi settings packages (npm, git, local).
// This picks up extensions like @howaboua/pi-glm-via-anthropic that
// register custom providers (e.g. glm-5.1) via registerProvider().
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -385,8 +395,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.filter((r) => r.enabled)
.map((r) => r.path);
// Load all extensions: filesystem-discovered + package-resolved
const extensionsResult = await discoverAndLoadExtensions(packageExtensionPaths, cwd, undefined);
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
const extensionsResult = await discoverAndLoadExtensions(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
console.log(`[extensions] Failed to load ${path}: ${error}`);

View File

@@ -10,6 +10,7 @@
*/
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
@@ -20,6 +21,7 @@ import {
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
@@ -29,7 +31,6 @@ import {
DefaultPackageManager,
ModelRegistry,
discoverAndLoadExtensions,
getAgentDir,
createExtensionRuntime,
} from "@mariozechner/pi-coding-agent";
import {
@@ -39,13 +40,22 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
let serveStartTime = 0;
let serveDbHealthCheck: (() => boolean) | null = null;
async function resolveRuntimeProjectPath(): Promise<string> {
try {
return (await resolveProject(undefined)).projectPath;
} catch {
return process.cwd();
}
}
/**
* Format bytes to human-readable string
*/
@@ -209,7 +219,7 @@ export async function runServe(
}
const selectedHost = opts.host ?? "0.0.0.0";
const cwd = process.cwd();
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
//
@@ -387,13 +397,13 @@ export async function runServe(
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const modelRegistry = new ModelRegistry(mergedAuthStorage);
const modelRegistry = new ModelRegistry(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
try {
const agentDir = getAgentDir();
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
@@ -405,9 +415,9 @@ export async function runServe(
.map((r) => r.path);
const extensionsResult = await discoverAndLoadExtensions(
packageExtensionPaths,
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths],
cwd,
undefined,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {

View File

@@ -14,8 +14,9 @@ import {
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolve, basename, extname } from "node:path";
import { resolve, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
@@ -36,19 +37,39 @@ const MIME_TYPES: Record<string, string> = {
".xml": "application/xml",
};
/** Cache stores per cwd to avoid re-init on every tool call. */
function resolveProjectRoot(cwd: string): string {
let current = resolve(cwd);
while (true) {
if (existsSync(join(current, ".fusion"))) {
return current;
}
const parent = resolve(current, "..");
if (parent === current) {
return resolve(cwd);
}
current = parent;
}
}
/** Cache stores per project root to avoid re-init on every tool call. */
const storeCache = new Map<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const existing = storeCache.get(cwd);
const projectRoot = resolveProjectRoot(cwd);
const existing = storeCache.get(projectRoot);
if (existing) return existing;
const store = new TaskStore(cwd);
const store = new TaskStore(projectRoot);
await store.init();
storeCache.set(cwd, store);
storeCache.set(projectRoot, store);
return store;
}
function getFusionDir(cwd: string): string {
return join(resolveProjectRoot(cwd), ".fusion");
}
function formatTaskLine(t: Task): string {
const label =
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
@@ -1534,7 +1555,7 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
@@ -1597,7 +1618,7 @@ export default function kbExtension(pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: ctx.cwd + "/.fusion" });
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
@@ -1759,7 +1780,7 @@ export default function kbExtension(pi: ExtensionAPI) {
// Execute via spawn
const child = spawn("npx", npxArgs, {
cwd: ctx.cwd,
cwd: resolveProjectRoot(ctx.cwd),
stdio: "pipe",
});
@@ -1864,7 +1885,7 @@ export default function kbExtension(pi: ExtensionAPI) {
// Find the fn binary: prefer local node_modules, then global
const child = spawn("fn", ["dashboard", "--port", String(port)], {
cwd: ctx.cwd,
cwd: resolveProjectRoot(ctx.cwd),
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },