fix(FN-XXX): harden windows path handling

This commit is contained in:
gsxdsm
2026-04-29 07:35:23 -07:00
parent dba6059080
commit bd14cf84e3
17 changed files with 189 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Windows path handling for worktree detection and home-directory lookups.

View File

@@ -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", () => {

View 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}`);
});
});

View File

@@ -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", () => {

View File

@@ -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}`;
}

View 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" },
]);
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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)", () => {

View File

@@ -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");
}

View File

@@ -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("/");

View File

@@ -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;

View File

@@ -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",
}));
});
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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", () => {