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\/core["']/);
|
||||||
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
|
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
|
||||||
expect(content).not.toMatch(/from\s+["']@fusion\/engine["']/);
|
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", () => {
|
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", () => {
|
describe("test isolation setup", () => {
|
||||||
it("process.env.HOME is overridden to a temp directory", () => {
|
it("process.env.HOME is overridden to a temp directory", () => {
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
|
const userProfile = process.env.USERPROFILE;
|
||||||
|
|
||||||
expect(home).toBeDefined();
|
expect(home).toBeDefined();
|
||||||
expect(home).toContain(tmpdir());
|
expect(home).toContain(tmpdir());
|
||||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||||
|
expect(userProfile).toBe(home);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("homedir() resolves to the temp HOME", () => {
|
it("homedir() resolves to the temp HOME", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
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";
|
const FUSION_DISABLED_EXTENSIONS_KEY = "fusionDisabledExtensions";
|
||||||
|
|
||||||
@@ -276,13 +276,28 @@ export function reconcileClaudeCliPaths(
|
|||||||
return filtered;
|
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 {
|
export function formatPiExtensionSource(source: PiExtensionSource, extensionPath: string, cwd: string, home?: string): string {
|
||||||
const homeDir = getHomeDir(home);
|
const homeDir = getHomeDir(home);
|
||||||
const projectRoot = resolvePiExtensionProjectRoot(cwd);
|
const projectRoot = resolvePiExtensionProjectRoot(cwd);
|
||||||
const relativePath = extensionPath.startsWith(homeDir)
|
const relativeToHome = getDisplayPathWithinRoot(homeDir, extensionPath);
|
||||||
? `~${extensionPath.slice(homeDir.length)}`
|
const relativeToProject = getDisplayPathWithinRoot(projectRoot, extensionPath);
|
||||||
: extensionPath.startsWith(projectRoot)
|
const relativePath = relativeToHome !== null
|
||||||
? relative(projectRoot, extensionPath).split(sep).join("/")
|
? relativeToHome.length > 0 ? `~/${relativeToHome}` : "~"
|
||||||
|
: relativeToProject !== null
|
||||||
|
? relativeToProject || "."
|
||||||
: extensionPath;
|
: extensionPath;
|
||||||
return `${source}: ${relativePath}`;
|
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", () => {
|
it("extracts humanized worktree names", () => {
|
||||||
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
|
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
|
||||||
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
|
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");
|
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 { writeFile, mkdir, access } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -531,3 +531,10 @@ describe("createSkillsAdapter - readSkillContent", () => {
|
|||||||
await cleanup(skillDir);
|
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).not.toBe(first);
|
||||||
expect(second).toHaveLength(0);
|
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)", () => {
|
describe("fetchGitHubCopilotUsage (via fetchAllProviderUsage)", () => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
writeProjectMemoryFile,
|
writeProjectMemoryFile,
|
||||||
updatePiExtensionDisabledIds,
|
updatePiExtensionDisabledIds,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||||
import { ApiError, badRequest } from "../api-error.js";
|
import { ApiError, badRequest } from "../api-error.js";
|
||||||
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
|
||||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let session: any = null;
|
let session: any = null;
|
||||||
try {
|
try {
|
||||||
await initCreateFnAgentForInsights();
|
|
||||||
if (!createFnAgentForInsights) {
|
if (!createFnAgentForInsights) {
|
||||||
throw new ApiError(503, "AI service unavailable for dream processing");
|
throw new ApiError(503, "AI service unavailable for dream processing");
|
||||||
}
|
}
|
||||||
@@ -1070,20 +1070,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
|
|
||||||
// ── Memory Insights Routes ───────────────────────────────────────────
|
// ── Memory Insights Routes ───────────────────────────────────────────
|
||||||
|
|
||||||
// Lazy-loaded createFnAgent for AI operations (same pattern as ai-refine.ts)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let createFnAgentForInsights: any;
|
const createFnAgentForInsights: any = engineCreateFnAgent;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/memory/insights
|
* GET /api/memory/insights
|
||||||
@@ -1148,8 +1136,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let session: any = null;
|
let session: any = null;
|
||||||
try {
|
try {
|
||||||
await initCreateFnAgentForInsights();
|
|
||||||
|
|
||||||
if (!createFnAgentForInsights) {
|
if (!createFnAgentForInsights) {
|
||||||
throw new ApiError(503, "AI engine not available");
|
throw new ApiError(503, "AI engine not available");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,9 +546,9 @@ export function createSkillsAdapter(options: {
|
|||||||
/**
|
/**
|
||||||
* Extract skill name from path and source.
|
* 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)
|
// 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) {
|
if (parts.length >= 2) {
|
||||||
// Return last two parts joined
|
// Return last two parts joined
|
||||||
return parts.slice(-2).join("/");
|
return parts.slice(-2).join("/");
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import * as https from "node:https";
|
|||||||
import * as child_process from "node:child_process";
|
import * as child_process from "node:child_process";
|
||||||
import { getAuthFileCandidates } from "./auth-paths.js";
|
import { getAuthFileCandidates } from "./auth-paths.js";
|
||||||
|
|
||||||
|
function getHomeDir(): string {
|
||||||
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||||
|
}
|
||||||
|
|
||||||
function execFileAsync(
|
function execFileAsync(
|
||||||
file: string,
|
file: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
@@ -846,8 +850,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|||||||
|
|
||||||
// ── Credential reading for plan detection & auth check ──────────────
|
// ── Credential reading for plan detection & auth check ──────────────
|
||||||
const credPaths = [
|
const credPaths = [
|
||||||
path.join(os.homedir(), ".claude", ".credentials.json"),
|
path.join(getHomeDir(), ".claude", ".credentials.json"),
|
||||||
path.join(os.homedir(), ".config", "claude", ".credentials.json"),
|
path.join(getHomeDir(), ".config", "claude", ".credentials.json"),
|
||||||
];
|
];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped 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
|
// 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");
|
const authPath = path.join(codexHome, "auth.json");
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped 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
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped OAuth JSON
|
||||||
let oauthCreds: any = null;
|
let oauthCreds: any = null;
|
||||||
try {
|
try {
|
||||||
@@ -1204,7 +1208,7 @@ async function fetchGeminiUsage(): Promise<ProviderUsage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check auth type from settings
|
// Check auth type from settings
|
||||||
const settingsPath = path.join(os.homedir(), ".gemini", "settings.json");
|
const settingsPath = path.join(getHomeDir(), ".gemini", "settings.json");
|
||||||
try {
|
try {
|
||||||
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||||
const authType = settings?.security?.auth?.selectedType;
|
const authType = settings?.security?.auth?.selectedType;
|
||||||
|
|||||||
@@ -433,5 +433,20 @@ describe("HybridExecutor", () => {
|
|||||||
executor.updateProject("non-existent", { maxConcurrent: 4 })
|
executor.updateProject("non-existent", { maxConcurrent: 4 })
|
||||||
).rejects.toThrow("Runtime not found");
|
).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 { 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 { createAgentSession, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
import { piLog } from "../logger.js";
|
import { piLog } from "../logger.js";
|
||||||
|
|
||||||
@@ -78,6 +78,18 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|||||||
let resolveSessionSkillsMock: ReturnType<typeof vi.fn>;
|
let resolveSessionSkillsMock: ReturnType<typeof vi.fn>;
|
||||||
let createSkillsOverrideFromSelectionMock: 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
|
// Initialize mocks before first test
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Access mocks from the mocked module
|
// Access mocks from the mocked module
|
||||||
|
|||||||
@@ -274,13 +274,19 @@ export class HybridExecutor extends EventEmitter<HybridExecutorEvents> {
|
|||||||
`Isolation mode changed for ${projectId}: ${currentMode} → ${config.isolationMode}`
|
`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
|
// Stop old runtime
|
||||||
await this.projectManager.removeProject(projectId);
|
await this.projectManager.removeProject(projectId);
|
||||||
|
|
||||||
// Get the full current config
|
// Get the full current config
|
||||||
const fullConfig: ProjectRuntimeConfig = {
|
const fullConfig: ProjectRuntimeConfig = {
|
||||||
projectId,
|
projectId,
|
||||||
workingDirectory: config.workingDirectory ?? "/tmp",
|
workingDirectory,
|
||||||
isolationMode: config.isolationMode,
|
isolationMode: config.isolationMode,
|
||||||
maxConcurrent: config.maxConcurrent ?? 2,
|
maxConcurrent: config.maxConcurrent ?? 2,
|
||||||
maxWorktrees: config.maxWorktrees ?? 4,
|
maxWorktrees: config.maxWorktrees ?? 4,
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
|
|||||||
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
|
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
|
||||||
* `/project` → null (not a worktree)
|
* `/project` → null (not a worktree)
|
||||||
*/
|
*/
|
||||||
function getProjectRootFromWorktree(cwd: string): string | null {
|
export function getProjectRootFromWorktree(cwd: string): string | null {
|
||||||
// Match paths like:
|
// Match paths like:
|
||||||
// /project/.worktrees/task-id
|
// /project/.worktrees/task-id
|
||||||
// /project/.worktrees/task-id/src/file.ts
|
// /project/.worktrees/task-id/src/file.ts
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ const TEMP_HOME_PREFIX = "fn-test-home-";
|
|||||||
describe("test isolation setup", () => {
|
describe("test isolation setup", () => {
|
||||||
it("overrides process.env.HOME to a temp directory", () => {
|
it("overrides process.env.HOME to a temp directory", () => {
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
|
const userProfile = process.env.USERPROFILE;
|
||||||
|
|
||||||
expect(home).toBeDefined();
|
expect(home).toBeDefined();
|
||||||
expect(home).toContain(tmpdir());
|
expect(home).toContain(tmpdir());
|
||||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||||
|
expect(userProfile).toBe(home);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves homedir() to the temp HOME", () => {
|
it("resolves homedir() to the temp HOME", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user