fix(server): keep home directory search out of ~/Library on macOS (#5789)

Discovery from the home directory walked into ~/Library, where readdir on
TCC-protected group containers blocks forever for a daemon that cannot show
a consent prompt. Each blocked call holds a libuv threadpool thread, so a few
searches wedged every fs operation in the daemon.

- Add an excludedDiscoveryPaths option; discovery skips those paths, while
  exact typed paths still resolve and browsing inside lists one level.
- Resolve rooted typed paths directly and anchor the walk under an existing
  typed parent instead of walking the whole home directory.

Refs #4879
This commit is contained in:
Leandro Ardissone
2026-10-08 11:33:32 +01:00
committed by GitHub
parent f943e790b6
commit 06fe97c901
3 changed files with 188 additions and 13 deletions
+8 -2
View File
@@ -15,7 +15,7 @@ import equal from "fast-deep-equal";
import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js";
import { v4 as uuidv4 } from "uuid";
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
import { basename, resolve, sep } from "path";
import { basename, join, resolve, sep } from "path";
import { homedir } from "node:os";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
@@ -5056,8 +5056,13 @@ export class Session {
try {
const workspaceCwd = cwd?.trim();
const searchesWorkspace = Boolean(workspaceCwd);
const homeRoot = process.env.HOME ?? homedir();
// readdir on TCC-protected folders under ~/Library blocks forever for a process that cannot
// show a consent prompt, and each blocked call holds a libuv threadpool thread.
const excludedDiscoveryPaths =
!searchesWorkspace && process.platform === "darwin" ? [join(homeRoot, "Library")] : [];
const entries = await searchDirectoryEntries({
root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()),
root: workspaceCwd ? expandTilde(workspaceCwd) : homeRoot,
query,
pathFormat: searchesWorkspace ? "relative" : "absolute",
pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted",
@@ -5068,6 +5073,7 @@ export class Session {
: [],
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
respectGitIgnore: searchesWorkspace,
excludedDiscoveryPaths,
includeFiles,
includeDirectories,
matchMode,
@@ -697,6 +697,105 @@ describe("absolute directory-path configuration", () => {
});
});
describe("home search with excluded discovery paths", () => {
let homeDir: string;
let libraryDir: string;
beforeEach(() => {
homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-excluded-")));
libraryDir = path.join(homeDir, "Library");
mkdirSync(path.join(libraryDir, "sub", "match-me"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "other"), { recursive: true });
});
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true });
});
function searchHome(query: string) {
return searchDirectoryEntries({
root: homeDir,
query,
pathFormat: "absolute",
includeDirectories: true,
includeFiles: false,
pathQueryPolicy: "rooted",
rootAliases: ["~"],
blankQueryBehavior: "none",
confidentResultScanThreshold: 5_000,
excludedDiscoveryPaths: [libraryDir],
}).then((entries) => entries.map((entry) => entry.path));
}
it("does not discover entries inside an excluded path", async () => {
await expect(searchHome("match")).resolves.toEqual([]);
await expect(searchHome("~")).resolves.toEqual([path.join(homeDir, "projects")]);
});
it("excludes by path, so a nested directory with the same name is still discovered", async () => {
const nested = path.join(homeDir, "projects", "game", "Library", "match-me");
mkdirSync(nested, { recursive: true });
await expect(searchHome("match")).resolves.toEqual([nested]);
});
// POSIX-only: creates and follows a symlink fixture.
it.skipIf(isWindows)("does not discover an excluded path through a symlink", async () => {
symlinkSync(path.join(libraryDir, "sub"), path.join(homeDir, "link"), "dir");
await expect(searchHome("match")).resolves.toEqual([]);
});
it("resolves an exact typed path inside an excluded path", async () => {
await expect(searchHome("~/Library/sub/match-me")).resolves.toEqual([
path.join(libraryDir, "sub", "match-me"),
]);
});
it("lists one level when browsing inside an excluded path", async () => {
await expect(searchHome("~/Library/")).resolves.toEqual([
libraryDir,
path.join(libraryDir, "sub"),
]);
await expect(searchHome("~/Library/su")).resolves.toEqual([path.join(libraryDir, "sub")]);
});
});
describe("rooted queries with a typed parent", () => {
let homeDir: string;
beforeEach(() => {
homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-typed-parent-")));
mkdirSync(path.join(homeDir, "Developer", "pasta"), { recursive: true });
mkdirSync(path.join(homeDir, "Archive", "Developer", "pasta"), { recursive: true });
});
afterEach(() => {
rmSync(homeDir, { recursive: true, force: true });
});
it("walks only under the typed parent when it exists", async () => {
await expect(
searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pas" }),
).resolves.toEqual([path.join(homeDir, "Developer", "pasta")]);
});
it("matches the whole tree when the typed parent does not exist", async () => {
await expect(searchAbsoluteDirectoryPaths({ homeDir, query: "~/Devel/pas" })).resolves.toEqual([
path.join(homeDir, "Developer", "pasta"),
path.join(homeDir, "Archive", "Developer", "pasta"),
]);
});
it("returns the typed directory first", async () => {
mkdirSync(path.join(homeDir, "Developer", "pasta-extra"));
const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pasta" });
expect(results[0]).toBe(path.join(homeDir, "Developer", "pasta"));
});
});
describe("relative typed-entry configuration", () => {
let tempRoot: string;
let workspaceDir: string;
@@ -33,6 +33,9 @@ export interface SearchDirectoryEntriesOptions {
maxEntriesScanned?: number;
confidentResultScanThreshold?: number;
respectGitIgnore?: boolean;
// Absolute paths that discovery never walks into. A path the caller types explicitly still
// resolves, and browsing inside one lists a single level at a time.
excludedDiscoveryPaths?: readonly string[];
}
interface QueryPlan {
@@ -55,6 +58,11 @@ interface RawChildEntry {
kind: DirectorySuggestionKind | "symlink";
}
interface WalkStart {
visiblePath: string;
resolvedPath: string;
}
interface TraversedEntry extends ChildEntry {
visiblePath: string;
depth: number;
@@ -174,29 +182,64 @@ export async function searchDirectoryEntries(
const gitIgnoredPaths = options.respectGitIgnore
? await loadGitIgnoredPaths(root)
: new Set<string>();
const input = buildSearchInput(options, root, gitIgnoredPaths);
const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []);
const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths);
if (!input) return [];
const exact =
input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery)
input.plan.browseExactPath ||
(input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input)))
? await findExactEntry(input)
: null;
if (exact && input.limit === 1) return [exact];
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
const browsesAbsoluteParent = input.plan.browseExactPath === true;
const ranked =
browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input);
const ranked = await searchCandidates(input);
const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit);
return exact
? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit)
: results;
}
async function searchCandidates(input: SearchInput): Promise<RankedEntry[]> {
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
const browsesAbsoluteParent = input.plan.browseExactPath === true;
if (browsesRoot || browsesAbsoluteParent) return searchChildren(input);
if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input);
// A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that
// does not exist falls back to matching the typed path against the whole tree.
const typedParent = await resolveTypedParent(input);
if (!typedParent) return searchTree(input);
if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input);
return searchTree(input, typedParent);
}
// Only a rooted query under the rooted policy names a location. Under the slashes policy a query
// with a slash is still a fragment that may match anywhere in the tree.
function resolvesTypedPath(input: SearchInput): boolean {
return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery;
}
async function resolveTypedParent(input: SearchInput): Promise<WalkStart | null> {
const visiblePath = path.resolve(input.root, input.plan.parentPart);
const resolvedPath = await realpath(visiblePath).catch(() => null);
if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null;
const info = await stat(resolvedPath).catch(() => null);
return info?.isDirectory() ? { visiblePath, resolvedPath } : null;
}
async function resolveExistingPaths(paths: readonly string[]): Promise<Set<string>> {
const resolved = await Promise.all(
paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)),
);
return new Set(resolved.filter((candidate): candidate is string => candidate !== null));
}
function buildSearchInput(
options: SearchDirectoryEntriesOptions,
root: string,
gitIgnoredPaths: Set<string>,
excludedDiscoveryPaths: Set<string>,
): SearchInput | null {
const includeDirectories = options.includeDirectories ?? true;
const includeFiles = options.includeFiles ?? false;
@@ -218,6 +261,7 @@ function buildSearchInput(
includeDirectories,
includeFiles,
matchMode: options.matchMode ?? "fuzzy",
pathQueryPolicy: options.pathQueryPolicy ?? "slashes",
pathFormat: options.pathFormat,
hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []),
limit: normalizeLimit(options.limit),
@@ -225,6 +269,7 @@ function buildSearchInput(
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED,
confidentResultScanThreshold: options.confidentResultScanThreshold,
gitIgnoredPaths,
excludedDiscoveryPaths,
};
}
@@ -252,6 +297,7 @@ interface SearchInput {
includeDirectories: boolean;
includeFiles: boolean;
matchMode: DirectorySuggestionMatchMode;
pathQueryPolicy: PathQueryPolicy;
pathFormat: DirectorySuggestionPathFormat;
hiddenDirectoryNames: Set<string>;
limit: number;
@@ -259,6 +305,7 @@ interface SearchInput {
maxEntriesScanned: number;
confidentResultScanThreshold: number | undefined;
gitIgnoredPaths: Set<string>;
excludedDiscoveryPaths: Set<string>;
}
async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
@@ -266,9 +313,13 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
const parent = await realpath(visibleParent).catch(() => null);
if (!parent || !isPathInsideRoot(input.root, parent)) return [];
if (isGitIgnoredPath(parent, input)) return [];
// Browsing inside an excluded path is explicit: the caller typed it, so list what is there.
const discoveryInput = isInsideExcludedPath(parent, input)
? { ...input, excludedDiscoveryPaths: new Set<string>() }
: input;
const entries = await readChildren(parent);
return entries.flatMap((entry) => {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input))
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
return [];
const candidate: TraversedEntry = {
...entry,
@@ -279,17 +330,20 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
});
}
async function searchTree(input: SearchInput): Promise<RankedEntry[]> {
async function searchTree(
input: SearchInput,
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(input.root)).filter((entry) =>
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([input.root]);
const visited = new Set<string>([start.resolvedPath]);
const branches = roots.flatMap((entry) =>
shouldDiscover(entry, input)
? [
walkBranch(
{ ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 },
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
input,
visited,
),
@@ -370,6 +424,7 @@ function staysInsideRoot(entry: ChildEntry, root: string): boolean {
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
if (isNewlyGitIgnored(entry, input)) return false;
if (isNewlyExcluded(entry, input)) return false;
if (entry.kind === "file") {
return input.includeFiles && !entry.name.startsWith(".");
}
@@ -386,6 +441,21 @@ function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean {
return input.gitIgnoredPaths.has(entry.resolvedPath);
}
// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without
// first passing through it by name.
function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean {
if (input.excludedDiscoveryPaths.size === 0) return false;
if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input);
return input.excludedDiscoveryPaths.has(entry.resolvedPath);
}
function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean {
for (const excludedPath of input.excludedDiscoveryPaths) {
if (isPathInsideRoot(excludedPath, absolutePath)) return true;
}
return false;
}
function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean {
let candidate = absolutePath;
while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) {