Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,12 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au
- New worktrees are created under `<repoRoot>-worktrees/<sanitized-branch>`. Existing branches are reused; otherwise `git worktree add -b` creates the branch.
- Removing a dirty worktree returns `409` with `{ dirty: true }` so the UI can ask before retrying with `force`.
- Sessions whose cwd points at a removed worktree are inferred back into the main project instead of becoming a phantom project row.
- git prints POSIX-style absolute paths even on Windows, so every path read out of git goes through `toNativePath()` (`lib/paths.ts`) before it is compared or returned. Compare paths with `samePath()`, never `===` — raw equality made `isTopLevel` permanently false on Windows and hid the worktree switcher entirely. Branch names are not paths and must keep their forward slashes.

### File access allow-list
- `/api/files` is intentionally not a general filesystem browser. Allowed roots come from session cwds, their resolved project roots, `~/pi-cwd-*`, and roots explicitly added with `allowFileRoot()`.
- `/api/cwd/validate`, `/api/default-cwd`, and `/api/worktrees` call `allowFileRoot()` when they make a new location browsable.
- Allowed roots are stored slash-normalized, but that is a Set-key convention, not a correctness requirement: `isPathWithinRoots()` (`lib/path-security.ts`, the single implementation behind `isFilePathAllowed()`) re-resolves and case-folds both sides, so either path form authorizes correctly. Keep that one implementation — it is the security boundary.

### Plugins and skills
- `/api/plugins` uses pi's `SettingsManager` + `DefaultPackageManager` for global/project package install, remove, update, enable, and disable. Disabling writes empty `extensions/skills/prompts/themes` arrays for that package entry.
Expand Down
9 changes: 8 additions & 1 deletion lib/allowed-roots.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { toSlashPath } from "./paths";

// In-memory roots that should be browsable in addition to roots derived from
// persisted sessions. Stored on globalThis so Next.js hot-reload keeps them.
declare global {
var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
var __piAdditionalAllowedRoots: Set<string> | undefined;
}

/**
* Allowed roots are internal bookkeeping keys that are never displayed, so they
* are stored slash-normalized for consistent Set membership. Correctness does
* not depend on it — isPathWithinRoots() re-normalizes whatever it is given.
*/
export function normalizeSlashes(filePath: string): string {
return filePath.replace(/\\/g, "/");
return toSlashPath(filePath);
}

export function getAdditionalAllowedRoots(): Set<string> {
Expand Down
6 changes: 5 additions & 1 deletion lib/file-access.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";

// Loaded through jiti so the module's own extensionless imports resolve the way
// the app resolves them (tsconfig moduleResolution: "bundler"); bare
// `import("./path-security.ts")` only works while that file has no imports.
async function loadSubject() {
return import("./path-security.ts");
const { createJiti } = await import("jiti");
return createJiti(import.meta.url).import("./path-security.ts");
}

test("rejects an existing path that escapes an allowed root through a symlink", async (t) => {
Expand Down
24 changes: 4 additions & 20 deletions lib/file-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { readdirSync } from "fs";
import { homedir } from "os";
import path from "path";
import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots";
import { isExistingPathWithinRoots } from "./path-security";
import { isExistingPathWithinRoots, isPathWithinRoots } from "./path-security";
import { listAllSessions } from "./session-reader";
export { allowFileRoot, normalizeSlashes } from "./allowed-roots";
export { isWindowsAbsolutePath } from "./paths";

// Short-TTL cache for the allowed-roots set. Without this, every file list/read
// request re-scans every pi session on disk just to check access. 5s is short
Expand All @@ -15,11 +16,6 @@ declare global {
}

const ALLOWED_ROOTS_TTL_MS = 5_000;
const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;

export function isWindowsAbsolutePath(filePath: string): boolean {
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
}

export async function getAllowedFileRoots(): Promise<Set<string>> {
const now = Date.now();
Expand Down Expand Up @@ -52,21 +48,9 @@ export async function getAllowedFileRoots(): Promise<Set<string>> {
return roots;
}

/** Authorize a path lexically, without touching the filesystem. */
export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
for (const root of allowedRoots) {
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
const resolver = useWindowsRules ? path.win32 : path;
const sep = useWindowsRules ? "\\" : path.sep;
const normalized = resolver.resolve(target);
const normalizedRoot = resolver.resolve(root);
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) {
return true;
}
}
return false;
return isPathWithinRoots(target, allowedRoots);
}

/** Authorize an existing path after resolving symbolic links. */
Expand Down
12 changes: 6 additions & 6 deletions lib/path-security.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { realpathSync } from "fs";
import path from "path";
import { isWindowsAbsolutePath } from "./paths";

const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;

function isWindowsAbsolutePath(filePath: string): boolean {
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
}

/**
* Lexical containment check. Accepts either canonical form on both sides: it
* re-resolves through path.win32/path.posix and case-folds on Windows, so
* separator style and drive-letter case never decide the answer.
*/
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
for (const root of roots) {
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
Expand Down
54 changes: 54 additions & 0 deletions lib/paths.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";

const isWindows = process.platform === "win32";

async function loadSubject() {
const { createJiti } = await import("jiti");
return createJiti(import.meta.url).import("./paths.ts");
}

test("toNativePath converts git's POSIX output to native separators", async () => {
const { toNativePath } = await loadSubject();
if (isWindows) {
// The regression this guards: `git rev-parse --path-format=absolute` prints
// `D:/repo` on Windows, which never string-compares equal to a session cwd.
assert.equal(toNativePath("D:/repo/sub"), "D:\\repo\\sub");
assert.equal(toNativePath("D:\\repo\\sub"), "D:\\repo\\sub");
} else {
assert.equal(toNativePath("/repo/sub"), "/repo/sub");
}
assert.equal(toNativePath(""), "");
});

test("samePath ignores separator style and Windows case", async () => {
const { samePath } = await loadSubject();
assert.equal(samePath("/a/b", "/a/b"), true);
assert.equal(samePath("/a/b", "/a/c"), false);
assert.equal(samePath("", ""), true);
assert.equal(samePath("", "/a"), false);

if (isWindows) {
assert.equal(samePath("D:/repo", "D:\\repo"), true, "separator style must not matter");
assert.equal(samePath("d:\\repo", "D:\\repo"), true, "drive-letter case must not matter");
assert.equal(samePath("D:\\Repo\\Sub", "d:/repo/sub"), true);
assert.equal(samePath("D:\\repo", "D:\\repo2"), false);
} else {
// POSIX is case-sensitive and backslash is a legal filename character.
assert.equal(samePath("/Repo", "/repo"), false);
}
});

test("toSlashPath normalizes to forward slashes", async () => {
const { toSlashPath } = await loadSubject();
assert.equal(toSlashPath("D:\\repo\\sub"), "D:/repo/sub");
assert.equal(toSlashPath("/repo/sub"), "/repo/sub");
});

test("isWindowsAbsolutePath recognizes drive and UNC paths", async () => {
const { isWindowsAbsolutePath } = await loadSubject();
assert.equal(isWindowsAbsolutePath("D:\\repo"), true);
assert.equal(isWindowsAbsolutePath("d:/repo"), true);
assert.equal(isWindowsAbsolutePath("\\\\server\\share"), true);
assert.equal(isWindowsAbsolutePath("relative/path"), false);
});
59 changes: 59 additions & 0 deletions lib/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { normalize } from "path";

// ============================================================================
// Path primitives.
//
// Two canonical forms coexist deliberately — pick by where the path is going:
//
// toNativePath() Native separators (`D:\repo` on Windows). Use for anything
// that reaches fs/path APIs, gets compared against a session
// cwd, or is shown to the user. This is the form pi records
// cwds in, so it is the default for user-facing paths.
//
// toSlashPath() Forward slashes (`D:/repo`). Use only for internal,
// never-displayed bookkeeping — the allowed-roots set, and
// separator-insensitive text matching. Containment checks
// re-normalize their inputs anyway (see path-security.ts),
// so this form is about consistent keys, not correctness.
//
// Comparison always goes through samePath()/isPathWithinRoots(), never `===`:
// git emits POSIX-style paths even on Windows, and Windows itself is
// case-insensitive, so raw string equality silently fails on both counts.
// ============================================================================

const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;

export function isWindowsAbsolutePath(filePath: string): boolean {
return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
}

/**
* Convert a path to native separators. Chiefly for git output: git prints
* POSIX-style absolute paths even on Windows (`D:/repo/sub`), which never
* string-compares equal to the native paths Node and pi produce.
*
* Only pass paths — a branch name like `feature/x` would become `feature\x`.
*/
export function toNativePath(p: string): string {
if (!p || process.platform !== "win32") return p;
return normalize(p);
}

/** Convert a path to forward slashes. See the form guidance above. */
export function toSlashPath(p: string): string {
return p.replace(/\\/g, "/");
}

/**
* Whether two paths denote the same location, tolerating separator style and —
* on Windows, where the filesystem is case-insensitive — case, including the
* drive letter (`d:\repo` vs `D:\repo`).
*
* Compares lexically: callers wanting symlinks resolved should realpath first.
*/
export function samePath(a: string, b: string): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (process.platform !== "win32") return false;
return toNativePath(a).toLowerCase() === toNativePath(b).toLowerCase();
}
16 changes: 10 additions & 6 deletions lib/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, realpathSync } from "fs";
import { basename, dirname, join, resolve } from "path";
import { promisify } from "util";
import { allowFileRoot } from "./allowed-roots";
import { samePath, toNativePath } from "./paths";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -90,7 +91,10 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
"--git-common-dir", "--git-dir", "--show-toplevel",
"--abbrev-ref", "HEAD",
]);
const [commonDir, gitDir, toplevel, ref] = out.split("\n").map((l) => l.trim());
const [commonDirRaw, gitDirRaw, toplevelRaw, ref] = out.split("\n").map((l) => l.trim());
// Only the first three lines are paths — `ref` is a branch name and must
// keep its forward slashes (`feature/foo`).
const [commonDir, gitDir, toplevel] = [commonDirRaw, gitDirRaw, toplevelRaw].map(toNativePath);
// git prints resolved (symlink-free) paths; normalize cwd the same way
let realCwd = cwd;
try { realCwd = realpathSync(cwd); } catch { /* keep as-is */ }
Expand All @@ -99,8 +103,8 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
// cwd is a subdirectory of a repo keeps its own project identity —
// grouping subdirs under the repo root would change where new sessions
// are created for existing users.
const isTopLevel = toplevel === realCwd;
const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
const isTopLevel = samePath(toplevel, realCwd);
const isWorktreeTopLevel = !samePath(gitDir, commonDir) && isTopLevel;
info = {
projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
branch: ref && ref !== "HEAD" ? ref : null,
Expand All @@ -126,7 +130,7 @@ export async function resolveProject(cwd: string): Promise<ProjectInfo> {
/** Main repo root (parent of the shared .git dir), or throws for non-git dirs */
async function getRepoRoot(cwd: string): Promise<string> {
const commonDir = await git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
return dirname(commonDir);
return dirname(toNativePath(commonDir));
}

export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
Expand All @@ -153,7 +157,7 @@ export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
for (const line of out.split("\n")) {
if (line.startsWith("worktree ")) {
flush();
current = { path: line.slice("worktree ".length).trim() };
current = { path: toNativePath(line.slice("worktree ".length).trim()) };
} else if (line.startsWith("branch ") && current) {
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
} else if (line.startsWith("prunable") && current) {
Expand Down Expand Up @@ -211,7 +215,7 @@ export async function addWorktree(cwd: string, branch: string): Promise<{ path:

export async function removeWorktree(cwd: string, worktreePath: string, force = false): Promise<void> {
const worktrees = await listWorktrees(cwd);
const target = worktrees.find((w) => w.path === worktreePath);
const target = worktrees.find((w) => samePath(w.path, worktreePath));
if (!target) throw new Error(`Not a worktree of this repository: ${worktreePath}`);
if (target.isMain) throw new Error("Cannot remove the main worktree");

Expand Down