fix(server): resolve exact file paths that git ignores (#3042)
Clicking a file path an agent mentioned failed with "No file found" whenever git ignored the file, which covers anything written under a scratch directory such as .dev/. Backticked paths do not open directly. They resolve through the directory-suggestions RPC, which the daemon runs with gitignore filtering on for every workspace search. findExactEntry resolved the path, confirmed it stayed inside the workspace root, and then discarded it on the ignore check. Ignore filtering belongs to discovery. It keeps build output out of the composer's @-mention picker and out of the directory chooser, and both of those keep it. It does not belong to retrieval of a path the caller named exactly, where containment inside the root is the only question left. That check is now dropped from the exact-path branch alone. This does not widen what is readable. The file-read path applies no ignore filtering at all; resolveScopedPath checks only root containment, before and after resolving symlinks. Every file this surfaces was already readable through the file explorer. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0906d1d7bd
commit
2740c8206f
@@ -1188,6 +1188,42 @@ test("finds workspace files inside the OpenCode directory", async () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("opens an exact gitignored workspace path without offering it as a suggestion", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-gitignored-suggestion-"));
|
||||
const target = path.join(cwd, "generated", "notes.md");
|
||||
|
||||
try {
|
||||
execSync("git init -q", { cwd });
|
||||
writeFileSync(path.join(cwd, ".gitignore"), "generated/\n");
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, "generated notes\n");
|
||||
writeFileSync(path.join(cwd, "notes.md"), "tracked notes\n");
|
||||
|
||||
const exactResult = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "generated/notes.md",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
const discoveryResult = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "notes",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(exactResult.error).toBeNull();
|
||||
expect(exactResult.entries).toEqual([{ path: "generated/notes.md", kind: "file" }]);
|
||||
expect(discoveryResult.error).toBeNull();
|
||||
expect(discoveryResult.entries).toEqual([{ path: "notes.md", kind: "file" }]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("receives server_info on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
|
||||
@@ -53,6 +53,7 @@ async function searchRelativeDirectoryEntries(options: {
|
||||
matchMode?: "fuzzy" | "suffix";
|
||||
maxDepth?: number;
|
||||
maxEntriesScanned?: number;
|
||||
respectGitIgnore?: boolean;
|
||||
}) {
|
||||
return searchDirectoryEntries({
|
||||
root: options.cwd,
|
||||
@@ -67,9 +68,15 @@ async function searchRelativeDirectoryEntries(options: {
|
||||
limit: options.limit,
|
||||
maxDepth: options.maxDepth,
|
||||
maxEntriesScanned: options.maxEntriesScanned,
|
||||
respectGitIgnore: options.respectGitIgnore,
|
||||
});
|
||||
}
|
||||
|
||||
function initGitRepo(directory: string, ignorePatterns: string): void {
|
||||
execFileSync("git", ["init", "-q"], { cwd: directory });
|
||||
writeFileSync(path.join(directory, ".gitignore"), ignorePatterns);
|
||||
}
|
||||
|
||||
describe("searchDirectoryEntries", () => {
|
||||
let configuredSearchRoot: string;
|
||||
let searchRoot: string;
|
||||
@@ -791,6 +798,68 @@ describe("relative typed-entry configuration", () => {
|
||||
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("resolves an exact gitignored path while keeping it out of discovery results", async () => {
|
||||
initGitRepo(workspaceDir, "generated/\n");
|
||||
mkdirSync(path.join(workspaceDir, "generated"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "generated", "notes.md"), "generated notes\n");
|
||||
|
||||
const exactResults = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "generated/notes.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
respectGitIgnore: true,
|
||||
});
|
||||
const fuzzyResults = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "notes",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
respectGitIgnore: true,
|
||||
});
|
||||
|
||||
expect({ exactResults, fuzzyResults }).toEqual({
|
||||
exactResults: [{ path: "generated/notes.md", kind: "file" }],
|
||||
fuzzyResults: [{ path: "docs/notes.md", kind: "file" }],
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(isWindows)(
|
||||
"refuses an exact path that escapes the root through a symlink",
|
||||
async () => {
|
||||
const outsideDir = path.join(tempRoot, "outside-workspace");
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
writeFileSync(path.join(outsideDir, "secret.md"), "outside\n");
|
||||
symlinkSync(outsideDir, path.join(workspaceDir, "escape-link"));
|
||||
symlinkSync(path.join(workspaceDir, "docs"), path.join(workspaceDir, "inside-link"));
|
||||
|
||||
const escaping = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "escape-link/secret.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
const staysInside = await searchRelativeDirectoryEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "inside-link/notes.md",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect({ escaping, staysInside }).toEqual({
|
||||
escaping: [],
|
||||
staysInside: [{ path: "inside-link/notes.md", kind: "file" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("traverses only allowlisted hidden directories without suggesting the directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
||||
|
||||
@@ -119,6 +119,11 @@ const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
||||
const gitIgnoredPathsCache = new Map<string, GitIgnoredPathsCacheEntry>();
|
||||
|
||||
// Discovery and retrieval filter differently, on purpose. Discovery — anything that ranks or
|
||||
// browses candidates the caller has not named — drops gitignored and hidden entries, so pickers
|
||||
// do not offer build output. Retrieval of a path the caller named exactly applies no ignore or
|
||||
// hidden filtering; the only question is whether the path stays inside the root. Clicking a file
|
||||
// reference an agent wrote must open it whether or not Git tracks it.
|
||||
export async function searchDirectoryEntries(
|
||||
options: SearchDirectoryEntriesOptions,
|
||||
): Promise<DirectorySuggestionEntry[]> {
|
||||
@@ -188,7 +193,8 @@ async function findExactEntry(input: SearchInput): Promise<DirectorySuggestionEn
|
||||
const visiblePath = path.resolve(input.root, input.plan.normalizedQuery);
|
||||
const resolvedPath = await realpath(visiblePath).catch(() => null);
|
||||
if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null;
|
||||
if (isGitIgnoredPath(resolvedPath, input)) return null;
|
||||
// No ignore filtering here: the caller named this exact path, so containment above is the
|
||||
// only question left to answer. Filtering belongs to discovery, not retrieval.
|
||||
const info = await stat(resolvedPath).catch(() => null);
|
||||
const kind = getEntryKind(info);
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user