fix(FN-XXX): harden windows path handling
This commit is contained in:
5
.changeset/fix-windows-path-handling.md
Normal file
5
.changeset/fix-windows-path-handling.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix Windows path handling for worktree detection and home-directory lookups.
|
||||
@@ -36,6 +36,9 @@ describe("CLI bundle output", () => {
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/core["']/);
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/engine["']/);
|
||||
expect(content).not.toContain('"@fusion/core"');
|
||||
expect(content).not.toContain('"@fusion/dashboard"');
|
||||
expect(content).not.toContain('"@fusion/engine"');
|
||||
});
|
||||
|
||||
it("contains inlined workspace code", () => {
|
||||
|
||||
38
packages/core/src/__tests__/pi-extensions-format.test.ts
Normal file
38
packages/core/src/__tests__/pi-extensions-format.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPiExtensionSource } from "../pi-extensions.js";
|
||||
|
||||
describe("formatPiExtensionSource", () => {
|
||||
it("formats project-relative extension paths", () => {
|
||||
expect(
|
||||
formatPiExtensionSource(
|
||||
"fusion-project",
|
||||
"/repo/.fusion/extensions/tooling/index.ts",
|
||||
"/repo",
|
||||
"/Users/alice",
|
||||
),
|
||||
).toBe("fusion-project: .fusion/extensions/tooling/index.ts");
|
||||
});
|
||||
|
||||
it("formats home-relative Windows extension paths", () => {
|
||||
expect(
|
||||
formatPiExtensionSource(
|
||||
"fusion-global",
|
||||
"C:\\Users\\alice\\.fusion\\agent\\extensions\\tooling\\index.ts",
|
||||
"C:\\repo",
|
||||
"C:\\Users\\alice",
|
||||
),
|
||||
).toBe("fusion-global: ~/.fusion/agent/extensions/tooling/index.ts");
|
||||
});
|
||||
|
||||
it("does not treat prefix-matching sibling paths as inside the project or home", () => {
|
||||
const projectLookalike = "C:\\repo-two\\.fusion\\extensions\\tooling\\index.ts";
|
||||
const homeLookalike = "C:\\Users\\alice-dev\\.fusion\\agent\\extensions\\tooling\\index.ts";
|
||||
|
||||
expect(
|
||||
formatPiExtensionSource("fusion-project", projectLookalike, "C:\\repo", "C:\\Users\\alice"),
|
||||
).toBe(`fusion-project: ${projectLookalike}`);
|
||||
expect(
|
||||
formatPiExtensionSource("fusion-global", homeLookalike, "C:\\repo", "C:\\Users\\alice"),
|
||||
).toBe(`fusion-global: ${homeLookalike}`);
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,12 @@ const TEMP_HOME_PREFIX = "fn-test-home-";
|
||||
describe("test isolation setup", () => {
|
||||
it("process.env.HOME is overridden to a temp directory", () => {
|
||||
const home = process.env.HOME;
|
||||
const userProfile = process.env.USERPROFILE;
|
||||
|
||||
expect(home).toBeDefined();
|
||||
expect(home).toContain(tmpdir());
|
||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||
expect(userProfile).toBe(home);
|
||||
});
|
||||
|
||||
it("homedir() resolves to the temp HOME", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join, relative, resolve, sep } from "node:path";
|
||||
import { basename, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
||||
|
||||
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
|
||||
|
||||
@@ -276,13 +276,28 @@ export function reconcileClaudeCliPaths(
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getDisplayPathWithinRoot(root: string, targetPath: string): string | null {
|
||||
const usesWindowsPaths = /^[A-Za-z]:[\\/]/.test(root) || /^[A-Za-z]:[\\/]/.test(targetPath) || root.includes("\\") || targetPath.includes("\\");
|
||||
const pathApi = usesWindowsPaths ? win32 : { relative, isAbsolute, sep };
|
||||
const rel = pathApi.relative(root, targetPath);
|
||||
if (rel === "") {
|
||||
return "";
|
||||
}
|
||||
if (!rel || rel === ".." || rel.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(rel)) {
|
||||
return null;
|
||||
}
|
||||
return rel.split(pathApi.sep).join("/");
|
||||
}
|
||||
|
||||
export function formatPiExtensionSource(source: PiExtensionSource, extensionPath: string, cwd: string, home?: string): string {
|
||||
const homeDir = getHomeDir(home);
|
||||
const projectRoot = resolvePiExtensionProjectRoot(cwd);
|
||||
const relativePath = extensionPath.startsWith(homeDir)
|
||||
? `~${extensionPath.slice(homeDir.length)}`
|
||||
: extensionPath.startsWith(projectRoot)
|
||||
? relative(projectRoot, extensionPath).split(sep).join("/")
|
||||
const relativeToHome = getDisplayPathWithinRoot(homeDir, extensionPath);
|
||||
const relativeToProject = getDisplayPathWithinRoot(projectRoot, extensionPath);
|
||||
const relativePath = relativeToHome !== null
|
||||
? relativeToHome.length > 0 ? `~/${relativeToHome}` : "~"
|
||||
: relativeToProject !== null
|
||||
? relativeToProject || "."
|
||||
: extensionPath;
|
||||
return `${source}: ${relativePath}`;
|
||||
}
|
||||
|
||||
43
packages/dashboard/app/utils/__tests__/pathDisplay.test.ts
Normal file
43
packages/dashboard/app/utils/__tests__/pathDisplay.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDisplayDirname,
|
||||
getParentDisplayPath,
|
||||
getPathBasename,
|
||||
getPathBreadcrumbs,
|
||||
getTrailingPath,
|
||||
joinDisplayPath,
|
||||
normalizeDisplayPath,
|
||||
splitPathSegments,
|
||||
} from "../pathDisplay";
|
||||
|
||||
describe("pathDisplay", () => {
|
||||
it("normalizes Windows separators for display", () => {
|
||||
expect(normalizeDisplayPath("C:\\repo\\src\\file.ts")).toBe("C:/repo/src/file.ts");
|
||||
});
|
||||
|
||||
it("extracts basenames and trailing paths across separators", () => {
|
||||
expect(getPathBasename("C:\\repo\\.worktrees\\quiet-falcon")).toBe("quiet-falcon");
|
||||
expect(getTrailingPath("C:\\Users\\alice\\project", 2)).toBe("alice/project");
|
||||
});
|
||||
|
||||
it("builds relative display paths for file browser navigation", () => {
|
||||
expect(splitPathSegments("src\\utils")).toEqual(["src", "utils"]);
|
||||
expect(joinDisplayPath("src\\utils", "file.ts")).toBe("src/utils/file.ts");
|
||||
expect(getParentDisplayPath("src\\utils")).toBe("src");
|
||||
expect(getDisplayDirname("src\\utils\\file.ts")).toBe("src/utils/");
|
||||
});
|
||||
|
||||
it("builds breadcrumbs for POSIX and Windows absolute paths", () => {
|
||||
expect(getPathBreadcrumbs("/usr/local")).toEqual([
|
||||
{ label: "/", path: "/" },
|
||||
{ label: "usr", path: "/usr" },
|
||||
{ label: "local", path: "/usr/local" },
|
||||
]);
|
||||
|
||||
expect(getPathBreadcrumbs("C:\\Users\\alice")).toEqual([
|
||||
{ label: "C:", path: "C:/" },
|
||||
{ label: "Users", path: "C:/Users" },
|
||||
{ label: "alice", path: "C:/Users/alice" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ describe("getWorktreeLabel", () => {
|
||||
it("extracts humanized worktree names", () => {
|
||||
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
|
||||
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
|
||||
expect(getWorktreeLabel("C:\\repo\\.worktrees\\quiet-falcon")).toBe("quiet-falcon");
|
||||
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createSkillsAdapter } from "../skills-adapter.js";
|
||||
import { createSkillsAdapter, extractSkillName } from "../skills-adapter.js";
|
||||
import { writeFile, mkdir, access } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -531,3 +531,10 @@ describe("createSkillsAdapter - readSkillContent", () => {
|
||||
await cleanup(skillDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSkillName", () => {
|
||||
it("normalizes Windows separators before deriving the display name", () => {
|
||||
expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix");
|
||||
expect(extractSkillName("windows-fix", "npm")).toBe("windows-fix");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,24 @@ describe("usage", () => {
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to USERPROFILE when HOME is unset", async () => {
|
||||
vi.stubEnv("HOME", "");
|
||||
vi.stubEnv("USERPROFILE", "/profiles/test-user");
|
||||
|
||||
mockReadFile.mockImplementation(async () => {
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
const readPaths = mockReadFile.mock.calls.map(([filePath]) => String(filePath));
|
||||
expect(readPaths).toContain("/profiles/test-user/.claude/.credentials.json");
|
||||
expect(readPaths).toContain("/profiles/test-user/.config/claude/.credentials.json");
|
||||
expect(readPaths).toContain("/profiles/test-user/.codex/auth.json");
|
||||
expect(readPaths).toContain("/profiles/test-user/.gemini/oauth_creds.json");
|
||||
expect(readPaths.some((filePath) => filePath.startsWith("/home/testuser/"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
writeProjectMemoryFile,
|
||||
updatePiExtensionDisabledIds,
|
||||
} from "@fusion/core";
|
||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
@@ -1008,7 +1009,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any = null;
|
||||
try {
|
||||
await initCreateFnAgentForInsights();
|
||||
if (!createFnAgentForInsights) {
|
||||
throw new ApiError(503, "AI service unavailable for dream processing");
|
||||
}
|
||||
@@ -1070,20 +1070,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
// ── Memory Insights Routes ───────────────────────────────────────────
|
||||
|
||||
// Lazy-loaded createFnAgent for AI operations (same pattern as ai-refine.ts)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createFnAgentForInsights: any;
|
||||
|
||||
async function initCreateFnAgentForInsights(): Promise<void> {
|
||||
if (createFnAgentForInsights) return;
|
||||
try {
|
||||
// Use dynamic import with @vite-ignore to prevent static analysis issues
|
||||
const engine = await import(/* @vite-ignore */ "@fusion/engine");
|
||||
createFnAgentForInsights = engine.createFnAgent;
|
||||
} catch {
|
||||
createFnAgentForInsights = undefined;
|
||||
}
|
||||
}
|
||||
const createFnAgentForInsights: any = engineCreateFnAgent;
|
||||
|
||||
/**
|
||||
* GET /api/memory/insights
|
||||
@@ -1148,8 +1136,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any = null;
|
||||
try {
|
||||
await initCreateFnAgentForInsights();
|
||||
|
||||
if (!createFnAgentForInsights) {
|
||||
throw new ApiError(503, "AI engine not available");
|
||||
}
|
||||
|
||||
@@ -546,9 +546,9 @@ export function createSkillsAdapter(options: {
|
||||
/**
|
||||
* Extract skill name from path and source.
|
||||
*/
|
||||
function extractSkillName(skillPath: string, source: string): string {
|
||||
export function extractSkillName(skillPath: string, source: string): string {
|
||||
// Get the last two path components (category/name or just name)
|
||||
const parts = skillPath.split("/").filter(Boolean);
|
||||
const parts = skillPath.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
// Return last two parts joined
|
||||
return parts.slice(-2).join("/");
|
||||
|
||||
@@ -5,6 +5,10 @@ import * as https from "node:https";
|
||||
import * as child_process from "node:child_process";
|
||||
import { getAuthFileCandidates } from "./auth-paths.js";
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
}
|
||||
|
||||
function execFileAsync(
|
||||
file: string,
|
||||
args: string[],
|
||||
@@ -846,8 +850,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
|
||||
// ── Credential reading for plan detection & auth check ──────────────
|
||||
const credPaths = [
|
||||
path.join(os.homedir(), ".claude", ".credentials.json"),
|
||||
path.join(os.homedir(), ".config", "claude", ".credentials.json"),
|
||||
path.join(getHomeDir(), ".claude", ".credentials.json"),
|
||||
path.join(getHomeDir(), ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped credentials JSON
|
||||
@@ -1067,7 +1071,7 @@ async function fetchCodexUsage(): Promise<ProviderUsage> {
|
||||
};
|
||||
|
||||
// Load Codex auth
|
||||
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||
const codexHome = process.env.CODEX_HOME || path.join(getHomeDir(), ".codex");
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped auth JSON
|
||||
@@ -1182,7 +1186,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
};
|
||||
|
||||
// Load Gemini OAuth credentials
|
||||
const oauthPath = path.join(os.homedir(), ".gemini", "oauth_creds.json");
|
||||
const oauthPath = path.join(getHomeDir(), ".gemini", "oauth_creds.json");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped OAuth JSON
|
||||
let oauthCreds: any = null;
|
||||
try {
|
||||
@@ -1204,7 +1208,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
||||
}
|
||||
|
||||
// Check auth type from settings
|
||||
const settingsPath = path.join(os.homedir(), ".gemini", "settings.json");
|
||||
const settingsPath = path.join(getHomeDir(), ".gemini", "settings.json");
|
||||
try {
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||
const authType = settings?.security?.auth?.selectedType;
|
||||
|
||||
@@ -433,5 +433,20 @@ describe("HybridExecutor", () => {
|
||||
executor.updateProject("non-existent", { maxConcurrent: 4 })
|
||||
).rejects.toThrow("Runtime not found");
|
||||
});
|
||||
|
||||
it("reuses the registered project path when isolation mode changes without an explicit working directory", async () => {
|
||||
const manager = mockProjectManagerInstances[0];
|
||||
manager?.addProject.mockClear();
|
||||
|
||||
await executor.updateProject("proj_test123", { isolationMode: "child-process" });
|
||||
|
||||
expect(mockCentralCore.getProject).toHaveBeenCalledWith("proj_test123");
|
||||
expect(manager?.removeProject).toHaveBeenCalledWith("proj_test123");
|
||||
expect(manager?.addProject).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: "proj_test123",
|
||||
workingDirectory: "/tmp/test-project",
|
||||
isolationMode: "child-process",
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, promptWithFallback, type AgentOptions } from "../pi.js";
|
||||
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, getProjectRootFromWorktree, promptWithFallback, type AgentOptions } from "../pi.js";
|
||||
import { createAgentSession, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { piLog } from "../logger.js";
|
||||
|
||||
@@ -78,6 +78,18 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
let resolveSessionSkillsMock: ReturnType<typeof vi.fn>;
|
||||
let createSkillsOverrideFromSelectionMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("getProjectRootFromWorktree", () => {
|
||||
it("detects POSIX worktree paths", () => {
|
||||
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001")).toBe("/repo");
|
||||
expect(getProjectRootFromWorktree("/repo/.worktrees/fn-001/src/file.ts")).toBe("/repo");
|
||||
});
|
||||
|
||||
it("detects Windows worktree paths", () => {
|
||||
expect(getProjectRootFromWorktree("C:\\repo\\.worktrees\\fn-001")).toBe("C:\\repo");
|
||||
expect(getProjectRootFromWorktree("C:\\repo\\.worktrees\\fn-001\\src\\file.ts")).toBe("C:\\repo");
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize mocks before first test
|
||||
beforeEach(() => {
|
||||
// Access mocks from the mocked module
|
||||
|
||||
@@ -274,13 +274,19 @@ export class HybridExecutor extends EventEmitter<HybridExecutorEvents> {
|
||||
`Isolation mode changed for ${projectId}: ${currentMode} → ${config.isolationMode}`
|
||||
);
|
||||
|
||||
const project = await this.centralCore.getProject(projectId);
|
||||
const workingDirectory = config.workingDirectory ?? project?.path;
|
||||
if (!workingDirectory) {
|
||||
throw new Error(`Project not found in CentralCore: ${projectId}`);
|
||||
}
|
||||
|
||||
// Stop old runtime
|
||||
await this.projectManager.removeProject(projectId);
|
||||
|
||||
// Get the full current config
|
||||
const fullConfig: ProjectRuntimeConfig = {
|
||||
projectId,
|
||||
workingDirectory: config.workingDirectory ?? "/tmp",
|
||||
workingDirectory,
|
||||
isolationMode: config.isolationMode,
|
||||
maxConcurrent: config.maxConcurrent ?? 2,
|
||||
maxWorktrees: config.maxWorktrees ?? 4,
|
||||
|
||||
@@ -791,7 +791,7 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
|
||||
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
|
||||
* `/project` → null (not a worktree)
|
||||
*/
|
||||
function getProjectRootFromWorktree(cwd: string): string | null {
|
||||
export function getProjectRootFromWorktree(cwd: string): string | null {
|
||||
// Match paths like:
|
||||
// /project/.worktrees/task-id
|
||||
// /project/.worktrees/task-id/src/file.ts
|
||||
|
||||
@@ -7,10 +7,12 @@ const TEMP_HOME_PREFIX = "fn-test-home-";
|
||||
describe("test isolation setup", () => {
|
||||
it("overrides process.env.HOME to a temp directory", () => {
|
||||
const home = process.env.HOME;
|
||||
const userProfile = process.env.USERPROFILE;
|
||||
|
||||
expect(home).toBeDefined();
|
||||
expect(home).toContain(tmpdir());
|
||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||
expect(userProfile).toBe(home);
|
||||
});
|
||||
|
||||
it("resolves homedir() to the temp HOME", () => {
|
||||
|
||||
Reference in New Issue
Block a user