mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Fixes #10488 ## Problem On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but pi compared paths as exact strings (`===`, `startsWith`, map and set keys). `realpathSync` keeps the input casing, so canonicalizing does not help. The reported case, with cwd `c:\Users\me\project` and home `C:\Users\me`: 1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion of the user dir `C:\Users\me\.agents\skills` does not match, so global skills load as project skills. 2. The same dir loads again as user skills. Dedup does not match either, so every skill is reported as a name collision. 3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a project resource that requires trust. The same pattern affected other places: - Saved trust decisions in `trust.json` were not found when cwd casing differed, so pi asked for trust again. - With a custom session dir, `--continue`, resume by id, and the session list hid sessions recorded with different cwd casing. - Git worktree `AGENTS.md` shadowing failed when cwd casing differed from git's paths. - Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and package identity across global and project settings. - Extension preload matching, skill scope classification, resource source info, the config and session selector keys, and the session import self-check. Separately, home-to-`~` shortening used an unanchored `startsWith(home)` in four places, so `/home/me2/x` was shown as `~2/x`. ## Fix New helpers in `utils/paths.ts`, all based on one comparison key: - `getPathComparisonKey(path)`: the resolved path, lowercased on Windows. It does not touch the filesystem. - `pathsEqual(a, b)` - `isPathWithin(parent, child)`: true for equal paths or paths inside `parent`, matching whole path segments. - `shortenHomePath(path, home?)`: replaces a leading home dir with `~`. The helpers pick `path.win32` or `path.posix` from `process.platform` at call time, so tests can exercise real Windows paths (drive letters, UNC). `getResourcePathKey(path)` in `source-info.ts` is the dedup key for resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay unchanged, because they are not filesystem paths and their lookup is case-sensitive. File paths go through realpath plus the comparison key. The helpers are used only for comparison; stored and displayed paths are unchanged. Trust store lookups and `set` match keys case-insensitively on Windows. Unchanged on purpose: - The package install-root guards (`resolveManagedPath`, `pruneEmptyGitParents`, the `node_modules` dependency check) keep exact string checks. Both sides come from the same root string, and exact is the stricter option. - Checks based on `path.relative()` (`getCwdRelativePath`, the changelog containment check) were already correct: Node's `path.win32.relative` ignores case. NTFS can enable per-directory case sensitivity, but it is rarely used outside WSL interop, so it is ignored (see the comment on `getPathComparisonKey`). ## Tests - `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX case sensitivity, segment-boundary containment, and home shortening, including root and UNC homes. - `test/source-info.test.ts`: file paths fold case on Windows; builtin and inline identifiers keep their case. - `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a lowercase `HOME`, plus saved trust decisions matched and replaced across casing (simulated win32). An end-to-end test of the reported loader scenario is not feasible on Linux: the loader resolves paths with POSIX `node:path`, and the filesystem is case-sensitive. `npm run check` passes. The coding-agent suite passes, except for 13 failures that also occur without this change: clipboard tool detection in this environment, and one test that needs built `dist/` output.
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
canonicalizePath,
|
|
getCwdRelativePath,
|
|
getPathComparisonKey,
|
|
isLocalPath,
|
|
isPathWithin,
|
|
normalizePath,
|
|
normalizeWindowsShellPath,
|
|
pathsEqual,
|
|
resolvePath,
|
|
shortenHomePath,
|
|
} from "../src/utils/paths.ts";
|
|
|
|
let tempDir: string;
|
|
|
|
afterEach(() => {
|
|
if (tempDir) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
tempDir = "";
|
|
}
|
|
});
|
|
|
|
function createTempDir(): string {
|
|
tempDir = mkdtempSync(join(tmpdir(), "pi-paths-"));
|
|
return tempDir;
|
|
}
|
|
|
|
describe("path comparison", () => {
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
|
|
afterEach(() => {
|
|
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
|
});
|
|
|
|
function simulatePlatform(platform: NodeJS.Platform): void {
|
|
Object.defineProperty(process, "platform", { value: platform });
|
|
}
|
|
|
|
// Regression test for https://github.com/earendil-works/pi/issues/10488
|
|
it("treats drive letters and path components case-insensitively on Windows", () => {
|
|
simulatePlatform("win32");
|
|
expect(getPathComparisonKey("c:\\Users\\me\\.agents\\skills")).toBe(
|
|
getPathComparisonKey("C:\\Users\\Me\\.agents\\skills"),
|
|
);
|
|
expect(pathsEqual("c:\\Users\\me", "C:/Users/Me/")).toBe(true);
|
|
expect(pathsEqual("C:\\Users\\me", "D:\\Users\\me")).toBe(false);
|
|
expect(isPathWithin("C:\\Users\\Me", "c:\\users\\me\\project")).toBe(true);
|
|
expect(isPathWithin("C:\\Users\\Me", "C:\\Users\\Me2")).toBe(false);
|
|
expect(isPathWithin("C:\\", "c:\\Users")).toBe(true);
|
|
expect(isPathWithin("\\\\server\\share", "\\\\SERVER\\Share\\folder")).toBe(true);
|
|
});
|
|
|
|
it("keeps casing on case-sensitive platforms", () => {
|
|
simulatePlatform("linux");
|
|
expect(pathsEqual("/home/me", "/home/Me")).toBe(false);
|
|
expect(isPathWithin("/home/me", "/home/Me/project")).toBe(false);
|
|
});
|
|
|
|
it("isPathWithin matches whole path segments", () => {
|
|
simulatePlatform("linux");
|
|
expect(isPathWithin("/home/me", "/home/me")).toBe(true);
|
|
expect(isPathWithin("/home/me", "/home/me/project")).toBe(true);
|
|
expect(isPathWithin("/home/me/", "/home/me/project")).toBe(true);
|
|
expect(isPathWithin("/home/me", "/home/me2")).toBe(false);
|
|
expect(isPathWithin("/", "/home")).toBe(true);
|
|
});
|
|
|
|
it("shortenHomePath replaces only a leading home directory", () => {
|
|
simulatePlatform("linux");
|
|
expect(shortenHomePath("/home/me", "/home/me")).toBe("~");
|
|
expect(shortenHomePath("/home/me/project", "/home/me")).toBe("~/project");
|
|
expect(shortenHomePath("/home/me2/project", "/home/me")).toBe("/home/me2/project");
|
|
expect(shortenHomePath("/home/Me/project", "/home/me")).toBe("/home/Me/project");
|
|
expect(shortenHomePath("/etc/passwd", "/")).toBe("~/etc/passwd");
|
|
expect(shortenHomePath("home/me/project", "/home/me")).toBe("home/me/project");
|
|
expect(shortenHomePath("/home/me/project", "")).toBe("/home/me/project");
|
|
});
|
|
|
|
it("shortenHomePath ignores casing and keeps separators on Windows", () => {
|
|
simulatePlatform("win32");
|
|
expect(shortenHomePath("c:\\users\\me\\project", "C:\\Users\\Me")).toBe("~\\project");
|
|
expect(shortenHomePath("\\\\server\\share\\folder", "\\\\server\\share")).toBe("~\\folder");
|
|
});
|
|
});
|
|
|
|
describe("canonicalizePath", () => {
|
|
it("returns the real path for a regular file", () => {
|
|
const dir = createTempDir();
|
|
const file = join(dir, "file.txt");
|
|
writeFileSync(file, "hello");
|
|
expect(canonicalizePath(file)).toBe(realpathSync(file));
|
|
});
|
|
|
|
it("resolves symlinks to their targets", () => {
|
|
const dir = createTempDir();
|
|
const target = join(dir, "target.txt");
|
|
const link = join(dir, "link.txt");
|
|
writeFileSync(target, "hello");
|
|
symlinkSync(target, link);
|
|
expect(canonicalizePath(link)).toBe(realpathSync(target));
|
|
});
|
|
|
|
it("resolves directory symlinks", () => {
|
|
const dir = createTempDir();
|
|
const targetDir = join(dir, "target-dir");
|
|
const linkDir = join(dir, "link-dir");
|
|
mkdirSync(targetDir);
|
|
symlinkSync(targetDir, linkDir, "dir");
|
|
expect(canonicalizePath(linkDir)).toBe(realpathSync(targetDir));
|
|
});
|
|
|
|
it("falls back to the raw path when the target does not exist", () => {
|
|
const dir = createTempDir();
|
|
const nonexistent = join(dir, "no-such-file");
|
|
expect(canonicalizePath(nonexistent)).toBe(nonexistent);
|
|
});
|
|
|
|
it("falls back to the raw path for a dangling symlink", () => {
|
|
const dir = createTempDir();
|
|
const target = join(dir, "target.txt");
|
|
const link = join(dir, "link.txt");
|
|
// Create a symlink whose target does not exist.
|
|
symlinkSync(target, link);
|
|
// realpathSync would throw, so canonicalizePath returns the link path.
|
|
expect(canonicalizePath(link)).toBe(link);
|
|
});
|
|
});
|
|
|
|
describe("getCwdRelativePath", () => {
|
|
it("keeps cwd-relative names that start with dots", () => {
|
|
const cwd = join(tmpdir(), "pi-paths-cwd");
|
|
expect(getCwdRelativePath(join(cwd, "..config", "AGENTS.md"), cwd)).toBe(join("..config", "AGENTS.md"));
|
|
});
|
|
|
|
it("rejects parent-directory traversals", () => {
|
|
const cwd = join(tmpdir(), "pi-paths-cwd");
|
|
expect(getCwdRelativePath(join(cwd, "..", "AGENTS.md"), cwd)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resolvePath", () => {
|
|
it("expands only home tilde shortcuts", () => {
|
|
const cwd = join(tmpdir(), "pi-paths-cwd");
|
|
expect(normalizePath("~")).toBe(homedir());
|
|
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
|
|
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
|
|
expect(normalizePath("~draft.md")).toBe("~draft.md");
|
|
});
|
|
|
|
it("resolves relative paths against the base directory", () => {
|
|
const cwd = join(tmpdir(), "pi-paths-cwd");
|
|
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
|
|
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
|
|
});
|
|
|
|
it("accepts file URLs", () => {
|
|
const dir = createTempDir();
|
|
const filePath = join(dir, "file with spaces.txt");
|
|
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
|
|
});
|
|
|
|
it("throws for invalid file URLs", () => {
|
|
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
|
|
});
|
|
|
|
it("preserves POSIX absolute paths with literal percent sequences", () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const dir = createTempDir();
|
|
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
|
|
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
|
|
}
|
|
});
|
|
|
|
it("does not treat Windows file URL pathname strings as native paths", () => {
|
|
if (process.platform !== "win32") {
|
|
return;
|
|
}
|
|
|
|
const dir = createTempDir();
|
|
const filePath = join(dir, "dir", "SKILL.md");
|
|
const pathname = pathToFileURL(filePath).pathname;
|
|
expect(pathname).toMatch(/^\/[A-Za-z]:/);
|
|
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
|
|
});
|
|
});
|
|
|
|
describe("normalizeWindowsShellPath", () => {
|
|
it("converts Git Bash, MSYS, Cygwin, and WSL drive paths", () => {
|
|
expect(normalizeWindowsShellPath("/c/Users/example/project")).toBe("C:\\Users\\example\\project");
|
|
expect(normalizeWindowsShellPath("/cygdrive/d/work")).toBe("D:\\work");
|
|
expect(normalizeWindowsShellPath("/mnt/e/source")).toBe("E:\\source");
|
|
expect(normalizeWindowsShellPath("/c")).toBe("C:\\");
|
|
});
|
|
|
|
it("leaves other path forms unchanged", () => {
|
|
for (const path of [
|
|
"C:/Users/example",
|
|
"C:\\Users\\example",
|
|
"//server/share/file",
|
|
"/c/Users\\example",
|
|
"relative/file",
|
|
"/tmp/file",
|
|
]) {
|
|
expect(normalizeWindowsShellPath(path)).toBe(path);
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform === "win32")("is applied by normal path handling on Windows", () => {
|
|
expect(normalizePath("/c/Users/example")).toBe("C:\\Users\\example");
|
|
expect(resolvePath("/mnt/c/Users/example", "D:\\work")).toBe(resolve("C:/Users/example"));
|
|
});
|
|
});
|
|
|
|
describe("isLocalPath", () => {
|
|
it("returns true for bare names", () => {
|
|
expect(isLocalPath("my-package")).toBe(true);
|
|
});
|
|
|
|
it("returns true for relative paths", () => {
|
|
expect(isLocalPath("./foo")).toBe(true);
|
|
});
|
|
|
|
it("returns true for file URLs", () => {
|
|
expect(isLocalPath("file:///tmp/foo")).toBe(true);
|
|
});
|
|
|
|
it("returns false for npm: protocol", () => {
|
|
expect(isLocalPath("npm:package")).toBe(false);
|
|
});
|
|
|
|
it("returns false for git: protocol", () => {
|
|
expect(isLocalPath("git://repo")).toBe(false);
|
|
});
|
|
|
|
it("returns false for https: protocol", () => {
|
|
expect(isLocalPath("https://example.com")).toBe(false);
|
|
});
|
|
});
|