fix(coding-agent): compare paths case-insensitively on Windows (#11588)

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.
This commit is contained in:
Petr Houška 2026-10-29 21:53:06 +01:00 • committed by GitHub
parent 1a09925d39
commit 1be312b46a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 262 additions and 129 deletions

View file

@ -35,6 +35,8 @@
- Fixed `tui.select.pageUp` and `tui.select.pageDown` being ignored in slash command and file autocomplete, `/settings`, and the model, scoped models, login, fork, `/mcp`, and llama.cpp selectors. In fullscreen mode, PageUp/PageDown now move an open list or autocomplete menu instead of scrolling the transcript, and a page key bound to Ctrl+V pages autocomplete instead of pasting an image ([#7629](https://github.com/earendil-works/pi/issues/7629), based on [#7680](https://github.com/earendil-works/pi/pull/7680) by [@muyiyr](https://github.com/muyiyr))
- Fixed tool output and `!` command output showing the parameters of control sequences with a `<`, `=` or `>` prefix as text, for example `35;59;1M` from a mouse report `ESC[<35;59;1M` or `0;276;0c` from a terminal reply `ESC[>0;276;0c`. Text after the keypad-mode sequences `ESC=` and `ESC>` is still kept ([#11377](https://github.com/earendil-works/pi/issues/11377))
- Fixed `pi -e npm:<pkg>` staying on the version from the first download. On each start and `/reload`, pi now reinstalls an `-e` npm package without an exact version when the registry has a newer matching version. `pi -e git:<repo>@<branch>` likewise stayed on its first checkout and is now fetched and reset to the branch; a commit-hash ref that is already checked out is not fetched. Offline mode and failed checks keep the cached copy ([#11375](https://github.com/earendil-works/pi/issues/11375), [#11590](https://github.com/earendil-works/pi/pull/11590))
- Fixed Windows paths being compared case-sensitively. When the working directory and home directory differed only in drive-letter casing (`c:\Users\me` vs `C:\Users\me`), skills in `~/.agents/skills` loaded twice, as project and user skills, and were reported as name collisions. The same mismatch also re-asked for saved project trust, hid sessions in a custom session directory from `--continue` and `/resume`, and stopped `pi remove` from matching configured local packages ([#10488](https://github.com/earendil-works/pi/issues/10488))
- Fixed paths next to the home directory being shown with a broken `~` prefix, for example `/home/me2/project` shown as `~2/project` when home is `/home/me`
## [1.4.0] - 2026-10-21

View file

@ -1,6 +1,6 @@
import { constants, copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, parse, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import { basename, join, parse } from "node:path";
import { pathsEqual, resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type {
@ -368,7 +368,7 @@ export class AgentSessionRuntime {
}
let destinationPath = join(sessionDir, basename(resolvedPath));
const sourceAlreadyStored = resolve(destinationPath) === resolvedPath;
const sourceAlreadyStored = pathsEqual(destinationPath, resolvedPath);
if (!sourceAlreadyStored) {
const { name, ext } = parse(destinationPath);
let suffix = 1;

View file

@ -11,7 +11,7 @@ import type { Provider } from "@earendil-works/pi-ai";
import type { KeyId } from "@earendil-works/pi-tui";
import type { createJiti } from "jiti";
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts";
import { resolvePath } from "../../utils/paths.ts";
import { pathsEqual, resolvePath } from "../../utils/paths.ts";
import { createEventBus, type EventBus } from "../event-bus.ts";
import type { ExecOptions } from "../exec.ts";
import { execCommand } from "../exec.ts";
@ -146,7 +146,7 @@ export function clearExtensionCache(): void {
function useExtensionCacheCwd(cwd: string): ExtensionCacheToken {
const resolvedCwd = resolvePath(cwd);
if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) {
if (extensionCacheCwd !== undefined && !pathsEqual(extensionCacheCwd, resolvedCwd)) {
clearExtensionCache();
}
extensionCacheCwd = resolvedCwd;

View file

@ -43,16 +43,18 @@ import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import {
canonicalizePath,
getPathComparisonKey,
isLocalPath,
markPathIgnoredByCloudSync,
normalizePath,
pathsEqual,
resolvePath,
} from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import { type PiManifest, readPiManifest } from "./pi-manifest.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
import { BUILTIN_PATH_PREFIX } from "./source-info.ts";
import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "./source-info.ts";
const NETWORK_TIMEOUT_MS = 10000;
const UPDATE_CHECK_CONCURRENCY = 4;
@ -1522,7 +1524,7 @@ export class DefaultPackageManager implements PackageManager {
if (parsed.type === "git") {
return `git:${parsed.host}/${parsed.path}`;
}
return `local:${this.resolvePath(parsed.path)}`;
return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`;
}
private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string {
@ -1534,7 +1536,7 @@ export class DefaultPackageManager implements PackageManager {
return `git:${parsed.host}/${parsed.path}`;
}
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`;
}
private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string {
@ -1839,9 +1841,9 @@ export class DefaultPackageManager implements PackageManager {
}
if (scope) {
const baseDir = this.getBaseDirForScope(scope);
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`;
}
return `local:${this.resolvePath(parsed.path)}`;
return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`;
}
/**
@ -2591,7 +2593,7 @@ export class DefaultPackageManager implements PackageManager {
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectTrusted = this.settingsManager.isProjectTrusted();
const projectAgentsSkillDirs = projectTrusted
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => !pathsEqual(dir, userAgentsSkillsDir))
: [];
const addResources = (
@ -2780,9 +2782,9 @@ export class DefaultPackageManager implements PackageManager {
const seen = new Set<string>();
return resolved.filter((entry) => {
const canonicalPath = canonicalizePath(entry.path);
if (seen.has(canonicalPath)) return false;
seen.add(canonicalPath);
const pathKey = getResourcePathKey(entry.path);
if (seen.has(pathKey)) return false;
seen.add(pathKey);
return true;
});
};

View file

@ -8,7 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { canonicalizePath, isLocalPath, isPathWithin, pathsEqual, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import {
@ -39,6 +39,7 @@ import { loadSkills } from "./skills.ts";
import {
BUILTIN_PATH_PREFIX,
createSourceInfo,
getResourcePathKey,
getSyntheticPathSource,
isSyntheticPath,
type SourceInfo,
@ -246,12 +247,12 @@ function findShadowedContextFile(cwd: string): string | undefined {
const mainRepoRoot = dirname(commonGitDir);
// False for an ordinary repo, where the two are the same dir, and for a sibling
// worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`)) return undefined;
if (!isPathWithin(mainRepoRoot, worktreeRoot) || pathsEqual(worktreeRoot, mainRepoRoot)) return undefined;
// dirname of the common git dir is the main worktree root only when that dir is
// itself checked out from the same repo. In a bare layout (`proj/.bare` +
// `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
// submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir) return undefined;
if (!pathsEqual(canonicalizePath(join(mainRepoRoot, ".git")), commonGitDir)) return undefined;
const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
}
@ -284,7 +285,7 @@ export function loadProjectContextFiles(options: {
const isShadowed =
shadowedContextFile !== undefined &&
contextFile !== null &&
join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile;
pathsEqual(join(canonicalizePath(currentDir), basename(contextFile.path)), shadowedContextFile);
if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
@ -750,6 +751,10 @@ export class DefaultResourceLoader implements ResourceLoader {
return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
}
private getExtensionPathKey(path: string): string {
return getResourcePathKey(this.resolveExtensionLoadPath(path));
}
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */
private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> {
const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX);
@ -792,26 +797,26 @@ export class DefaultResourceLoader implements ResourceLoader {
const preloadedByPath = new Map(
preloaded
.filter((extension) => !extension.path.startsWith("<inline:"))
.map((extension) => [extension.resolvedPath, extension]),
.map((extension) => [getResourcePathKey(extension.resolvedPath), extension]),
);
const failedPreloadPaths = new Set(
(preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)),
(preTrustExtensions?.errors ?? []).map((error) => this.getExtensionPathKey(error.path)),
);
const remainingPaths = extensionPaths.filter((path) => {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
const pathKey = this.getExtensionPathKey(path);
return !preloadedByPath.has(pathKey) && !failedPreloadPaths.has(pathKey);
});
const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime);
const loadedByPath = new Map(preloadedByPath);
for (const extension of remainingExtensions.extensions) {
loadedByPath.set(extension.resolvedPath, extension);
loadedByPath.set(getResourcePathKey(extension.resolvedPath), extension);
}
const inlineExtensions = preTrustExtensions
? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] }
: await this.loadExtensionFactories(remainingExtensions.runtime);
const orderedExtensions = extensionPaths
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
.map((path) => loadedByPath.get(this.getExtensionPathKey(path)))
.filter((extension): extension is Extension => extension !== undefined);
orderedExtensions.push(...inlineExtensions.extensions);
@ -980,11 +985,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const normalizedResourcePath = resolve(resourcePath);
if (extraSourceInfos) {
for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) {
const normalizedSourcePath = resolve(sourcePath);
if (
normalizedResourcePath === normalizedSourcePath ||
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
) {
if (isPathWithin(sourcePath, normalizedResourcePath)) {
return { ...sourceInfo, path: resourcePath };
}
}
@ -997,11 +998,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const [sourcePath, metadata] of metadataByPath.entries()) {
const normalizedSourcePath = resolve(sourcePath);
if (
normalizedResourcePath === normalizedSourcePath ||
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
) {
if (isPathWithin(sourcePath, normalizedResourcePath)) {
return createSourceInfo(resourcePath, metadata);
}
}
@ -1062,9 +1059,9 @@ export class DefaultResourceLoader implements ResourceLoader {
for (const p of [...primary, ...additional]) {
const resolved = this.resolveResourcePath(p);
const canonicalPath = canonicalizePath(resolved);
if (seen.has(canonicalPath)) continue;
seen.add(canonicalPath);
const pathKey = getResourcePathKey(resolved);
if (seen.has(pathKey)) continue;
seen.add(pathKey);
merged.push(resolved);
}

View file

@ -30,7 +30,7 @@ import { basename, join, resolve } from "path";
import { createInterface } from "readline";
import { StringDecoder } from "string_decoder";
import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { normalizePath, pathsEqual, resolvePath } from "../utils/paths.ts";
import {
type BashExecutionMessage,
type CustomMessage,
@ -742,7 +742,7 @@ function getSessionHeaderCwd(header: SessionHeader): string | undefined {
}
function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {
return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
return cwd !== undefined && cwd !== "" && pathsEqual(resolvePath(cwd), resolvedCwd);
}
/** Exported for testing */
@ -1146,7 +1146,7 @@ export class SessionManager {
}
usesDefaultSessionDir(): boolean {
return this.sessionDir === getDefaultSessionDirPath(this.cwd);
return pathsEqual(this.sessionDir, getDefaultSessionDirPath(this.cwd));
}
getSessionId(): string {
@ -1802,7 +1802,7 @@ export class SessionManager {
*/
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
@ -1888,7 +1888,7 @@ export class SessionManager {
*/
static findById(cwd: string, id: string, sessionDir?: string): string | undefined {
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
const resolvedCwd = resolvePath(cwd);
try {
@ -1919,7 +1919,7 @@ export class SessionManager {
signal?: AbortSignal,
): Promise<SessionInfo[]> {
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
const resolvedCwd = resolvePath(cwd);
const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd);
const progress: SessionListProgress | undefined = onProgress

View file

@ -3,9 +3,9 @@ import ignore from "ignore";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import { isPathWithin, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
import { createSyntheticSourceInfo, getResourcePathKey, type SourceInfo } from "./source-info.ts";
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
@ -427,7 +427,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
allDiagnostics.push(...result.diagnostics);
for (const skill of result.skills) {
// Resolve symlinks to detect duplicate files
const realPath = canonicalizePath(skill.filePath);
const realPath = getResourcePathKey(skill.filePath);
// Skip silently if we've already loaded this exact file (via symlink)
if (realPathSet.has(realPath)) {
@ -462,19 +462,10 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root);
if (target === normalizedRoot) {
return true;
}
const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return target.startsWith(prefix);
};
const getSource = (resolvedPath: string): "user" | "project" | "path" => {
if (!includeDefaults) {
if (isUnderPath(resolvedPath, userSkillsDir)) return "user";
if (isUnderPath(resolvedPath, projectSkillsDir)) return "project";
if (isPathWithin(userSkillsDir, resolvedPath)) return "user";
if (isPathWithin(projectSkillsDir, resolvedPath)) return "project";
}
return "path";
};

View file

@ -1,3 +1,4 @@
import { canonicalizePath, getPathComparisonKey } from "../utils/paths.ts";
import type { PathMetadata } from "./package-manager.ts";
export type SourceScope = "user" | "project" | "temporary";
@ -41,6 +42,11 @@ export function isSyntheticPath(path: string): boolean {
return path.startsWith(BUILTIN_PATH_PREFIX) || path.startsWith("<");
}
/** Key for deduplicating resource paths: synthetic paths as-is, file paths via realpath and platform casing. */
export function getResourcePathKey(path: string): string {
return isSyntheticPath(path) ? path : getPathComparisonKey(canonicalizePath(path));
}
export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo {
return {
path,

View file

@ -1,20 +1,15 @@
import * as os from "node:os";
import { pathToFileURL } from "node:url";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../utils/ansi.ts";
import { resolvePath } from "../../utils/paths.ts";
import { resolvePath, shortenHomePath } from "../../utils/paths.ts";
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
import type { ToolRenderContext } from "../extensions/types.ts";
export function shortenPath(path: unknown): string {
if (typeof path !== "string") return "";
const home = os.homedir();
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
return shortenHomePath(path);
}
export function linkPath(styledText: string, rawPath: string, cwd: string): string {

View file

@ -3,7 +3,7 @@ import { homedir } from "node:os";
import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME } from "../config.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import { canonicalizePath, pathsEqual, resolvePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
export type ProjectTrustDecision = boolean | null;
@ -42,12 +42,19 @@ function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
/** Trust file keys naming `path`. Usually one; several on Windows when saved with different casing. */
function findTrustFileKeys(data: TrustFile, path: string): string[] {
return Object.keys(data).filter((key) => pathsEqual(key, path));
}
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
let currentDir = normalizeCwd(cwd);
while (true) {
const value = data[currentDir];
if (value === true || value === false) {
return { path: currentDir, decision: value };
for (const key of findTrustFileKeys(data, currentDir)) {
const value = data[key];
if (value === true || value === false) {
return { path: key, decision: value };
}
}
const parentDir = dirname(currentDir);
@ -195,7 +202,7 @@ export function hasTrustRequiringProjectResources(cwd: string): boolean {
while (true) {
const agentsSkillsDir = join(currentDir, ".agents", "skills");
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
if (!pathsEqual(agentsSkillsDir, userAgentsSkillsDir) && existsSync(agentsSkillsDir)) {
return true;
}
@ -234,9 +241,10 @@ export class ProjectTrustStore {
const data = readTrustFile(this.trustPath);
for (const { path, decision } of decisions) {
const key = normalizeCwd(path);
if (decision === null) {
delete data[key];
} else {
for (const existingKey of findTrustFileKeys(data, key)) {
delete data[existingKey];
}
if (decision !== null) {
data[key] = decision;
}
}

View file

@ -2,7 +2,6 @@
* TUI component for managing package resources (enable/disable)
*/
import { homedir } from "node:os";
import { basename, dirname, join, relative } from "node:path";
import {
type Component,
@ -18,8 +17,8 @@ import {
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { BUILTIN_PATH_PREFIX } from "../../../core/source-info.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "../../../core/source-info.ts";
import { isLocalPath, resolvePath, shortenHomePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
@ -65,19 +64,7 @@ interface ResourceGroup {
}
function formatBaseDir(baseDir: string): string {
const homeDir = homedir();
let displayPath: string;
if (baseDir === homeDir) {
displayPath = "~";
} else if (baseDir.startsWith(homeDir)) {
// Replace home prefix with ~, normalize separators for display
const rest = baseDir.slice(homeDir.length);
displayPath = `~${rest.replace(/\\/g, "/")}`;
} else {
displayPath = baseDir.replace(/\\/g, "/");
}
const displayPath = shortenHomePath(baseDir).replace(/\\/g, "/");
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
}
@ -848,7 +835,7 @@ class ResourceList implements Component, Focusable {
}
private getResourceItemKey(item: ResourceItem): string {
return `${item.resourceType}:${canonicalizePath(item.path)}`;
return `${item.resourceType}:${getResourcePathKey(item.path)}`;
}
private getItemScope(item: ResourceItem): SettingsScope {

View file

@ -1,7 +1,6 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { unlink } from "node:fs/promises";
import * as os from "node:os";
import {
type Component,
Container,
@ -15,7 +14,7 @@ import {
} from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../../../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts";
import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts";
import { canonicalizePath, getPathComparisonKey, shortenHomePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
@ -23,15 +22,6 @@ import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode }
type SessionScope = "current" | "all";
function shortenPath(path: string): string {
const home = os.homedir();
if (!path) return path;
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
function formatSessionDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@ -48,9 +38,9 @@ function formatSessionDate(date: Date): string {
return `${Math.floor(diffDays / 365)}y`;
}
function canonicalizePath(path: string | undefined): string | undefined {
if (!path) return path;
return _canonicalizePath(path);
/** Comparison key for a session file path; follows symlinks and ignores case on Windows. */
function getSessionPathKey(path: string): string {
return getPathComparisonKey(canonicalizePath(path));
}
class SessionSelectorHeader implements Component {
@ -210,16 +200,16 @@ function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] {
const byPath = new Map<string, SessionTreeNode>();
for (const session of sessions) {
const sessionPath = canonicalizePath(session.path) ?? session.path;
const sessionPath = getSessionPathKey(session.path);
byPath.set(sessionPath, { session, children: [], latestActivity: session.modified.getTime() });
}
const roots: SessionTreeNode[] = [];
for (const session of sessions) {
const sessionPath = canonicalizePath(session.path) ?? session.path;
const sessionPath = getSessionPathKey(session.path);
const node = byPath.get(sessionPath)!;
const parentPath = canonicalizePath(session.parentSessionPath);
const parentPath = session.parentSessionPath ? getSessionPathKey(session.parentSessionPath) : undefined;
if (parentPath && byPath.has(parentPath)) {
byPath.get(parentPath)!.children.push(node);
@ -296,7 +286,7 @@ class SessionList implements Component, Focusable {
private keybindings: KeybindingsManager;
private showPath = false;
private confirmingDeletePath: string | null = null;
private currentSessionCanonicalPath?: string;
private currentSessionPathKey?: string;
public onSelect?: (sessionPath: string) => void;
public onCancel?: () => void;
public onExit: () => void = () => {};
@ -335,7 +325,7 @@ class SessionList implements Component, Focusable {
this.sortMode = sortMode;
this.nameFilter = nameFilter;
this.keybindings = keybindings;
this.currentSessionCanonicalPath = canonicalizePath(currentSessionFilePath);
this.currentSessionPathKey = currentSessionFilePath ? getSessionPathKey(currentSessionFilePath) : undefined;
this.filterSessions("");
// Handle Enter in search input - select current item
@ -413,8 +403,8 @@ class SessionList implements Component, Focusable {
}
private isCurrentSessionPath(path: string): boolean {
if (!this.currentSessionCanonicalPath) return false;
return (canonicalizePath(path) ?? path) === this.currentSessionCanonicalPath;
if (!this.currentSessionPathKey) return false;
return getSessionPathKey(path) === this.currentSessionPathKey;
}
invalidate(): void {}
@ -474,10 +464,10 @@ class SessionList implements Component, Focusable {
const msgCount = String(session.messageCount);
let rightPart = `${msgCount} ${age}`;
if (this.showCwd && session.cwd) {
rightPart = `${shortenPath(session.cwd)} ${rightPart}`;
rightPart = `${shortenHomePath(session.cwd)} ${rightPart}`;
}
if (this.showPath) {
rightPart = `${shortenPath(session.path)} ${rightPart}`;
rightPart = `${shortenHomePath(session.path)} ${rightPart}`;
}
// Cursor

View file

@ -14,6 +14,7 @@ import {
} from "@earendil-works/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.ts";
import { formatReadLineRange } from "../../../core/tools/render-utils.ts";
import { shortenHomePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
@ -945,11 +946,7 @@ class TreeList implements Component {
}
private formatToolCall(name: string, args: Record<string, unknown>): string {
const shortenPath = (p: string): string => {
const home = process.env.HOME || process.env.USERPROFILE || "";
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
return p;
};
const shortenPath = (p: string): string => shortenHomePath(p, process.env.HOME || process.env.USERPROFILE || "");
switch (name) {
case "read": {

View file

@ -127,7 +127,7 @@ import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../.
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { ensurePngTranscoder } from "../../utils/image-convert.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getCwdRelativePath, shortenHomePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts";
@ -1474,15 +1474,7 @@ export class InteractiveMode {
// =========================================================================
private formatDisplayPath(p: string): string {
const home = os.homedir();
let result = p;
// Replace home directory with ~
if (result.startsWith(home)) {
result = `~${result.slice(home.length)}`;
}
return result;
return shortenHomePath(p);
}
private formatExtensionDisplayPath(path: string): string {

View file

@ -1,6 +1,6 @@
import { realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
import { isAbsolute, join, resolve as nodeResolvePath, posix, relative, sep, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnProcessSync } from "./child-process.ts";
@ -33,6 +33,50 @@ export function canonicalizePath(path: string): string {
}
}
/**
* Key for comparing filesystem paths: the resolved absolute path, case-folded
* on Windows because Windows paths are case-insensitive (`c:\Users` and
* `C:\Users` are the same directory). NTFS can enable per-directory case
* sensitivity, but it is rarely used outside WSL interop, so it is ignored
* here (https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity).
* Does not touch the filesystem; call `canonicalizePath` first when symlinks
* must compare equal. Use only for comparison, never for display or
* filesystem access.
*/
export function getPathComparisonKey(path: string): string {
const pathApi = getPlatformPathApi();
const resolvedPath = pathApi.resolve(path);
return pathApi === win32 ? resolvedPath.toLowerCase() : resolvedPath;
}
/** `node:path` rules for the current platform, read at call time so tests can simulate Windows. */
function getPlatformPathApi(): typeof win32 {
return process.platform === "win32" ? win32 : posix;
}
/** True when both paths name the same location (see `getPathComparisonKey`). */
export function pathsEqual(left: string, right: string): boolean {
return getPathComparisonKey(left) === getPathComparisonKey(right);
}
/** True when `child` is `parent` or inside it (see `getPathComparisonKey`). */
export function isPathWithin(parent: string, child: string): boolean {
const parentKey = getPathComparisonKey(parent);
const childKey = getPathComparisonKey(child);
if (childKey === parentKey) return true;
// Filesystem roots (`/`, `C:\`) already end with a separator.
const pathSep = getPlatformPathApi().sep;
return childKey.startsWith(parentKey.endsWith(pathSep) ? parentKey : `${parentKey}${pathSep}`);
}
/** Replace a leading home directory with `~` for display. Relative paths are returned unchanged. */
export function shortenHomePath(path: string, homeDir: string = homedir()): string {
const pathApi = getPlatformPathApi();
if (!homeDir || !pathApi.isAbsolute(path) || !isPathWithin(homeDir, path)) return path;
const relativePath = pathApi.relative(homeDir, path);
return relativePath ? `~${pathApi.sep}${relativePath}` : "~";
}
export function getFileRevision(path: string): string | undefined {
try {
const stats = statSync(path, { bigint: true });

View file

@ -6,10 +6,14 @@ 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;
@ -26,6 +30,64 @@ function createTempDir(): string {
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();

View file

@ -0,0 +1,19 @@
import { afterEach, describe, expect, it } from "vitest";
import { getResourcePathKey } from "../src/core/source-info.ts";
describe("getResourcePathKey", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
afterEach(() => {
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
});
// Regression test for https://github.com/earendil-works/pi/issues/10488
it("folds file path casing on Windows but keeps synthetic identifiers unchanged", () => {
Object.defineProperty(process, "platform", { value: "win32" });
expect(getResourcePathKey("C:\\Users\\Me\\ext.ts")).toBe(getResourcePathKey("c:\\users\\me\\ext.ts"));
expect(getResourcePathKey("builtin:MCP")).toBe("builtin:MCP");
expect(getResourcePathKey("builtin:MCP")).not.toBe(getResourcePathKey("builtin:mcp"));
expect(getResourcePathKey("<inline:Name>")).toBe("<inline:Name>");
});
});

View file

@ -1,4 +1,4 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@ -64,4 +64,45 @@ describe("ProjectTrustStore", () => {
}
}
});
// Regression test for https://github.com/earendil-works/pi/issues/10488
it("ignores the user .agents/skills dir when HOME differs only by case on Windows", () => {
const originalHome = process.env.HOME;
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const mixedCaseHome = join(tempDir, "Home");
const projectDir = join(mixedCaseHome, "project");
mkdirSync(join(mixedCaseHome, ".agents", "skills"), { recursive: true });
mkdirSync(projectDir, { recursive: true });
process.env.HOME = mixedCaseHome.toLowerCase();
Object.defineProperty(process, "platform", { value: "win32" });
try {
expect(hasTrustRequiringProjectResources(projectDir)).toBe(false);
} finally {
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
}
});
// Regression test for https://github.com/earendil-works/pi/issues/10488
it("matches saved decisions regardless of casing on Windows", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const projectDir = join(tempDir, "Project-Mixed");
mkdirSync(projectDir, { recursive: true });
const store = new ProjectTrustStore(agentDir);
store.set(projectDir, true);
Object.defineProperty(process, "platform", { value: "win32" });
try {
expect(store.get(projectDir.toLowerCase())).toBe(true);
store.set(projectDir.toLowerCase(), false);
const saved = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf-8")) as Record<string, boolean>;
expect(Object.values(saved)).toEqual([false]);
expect(store.get(projectDir)).toBe(false);
} finally {
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
}
});
});