fix(ui): scope file-reference stat probes to the session directory (#3022)

* test: add regression test for stat directory scoping (#3019)

* fix: scope file-reference stat probes to the session directory (#3019)

The /api/fs/stat probe sent by the markdown renderer carried no
directory hint, so the server resolved the workspace from
settings.lastDirectory. When the browsed directory differs from the
session directory the stat returns 400 and the renderer treats it as
file-does-not-exist, silently disabling file-reference links.

Send effectiveDirectory as x-opencode-directory on the probe and
qualify the stat cache key by directory so a rejection under one
directory cannot leak into another. The probe logic moves from
MarkdownRendererImpl into fileReferenceStat with unit coverage.
This commit is contained in:
Tom Rochette
2026-08-21 16:48:32 +03:00
committed by GitHub
parent ad5bc2b01b
commit a79ae57ae5
4 changed files with 225 additions and 67 deletions
@@ -4,7 +4,6 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
@@ -42,6 +41,7 @@ import {
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
const useCurrentMermaidTheme = () => {
@@ -151,19 +151,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// output. The regex is defined in `./fileReferenceParser`; the inline-code
// pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -361,61 +351,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
@@ -521,7 +456,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { fileReferenceExists } from './fileReferenceStat';
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Headers }> = [];
const stubFetchWith = (respond: () => Response) => {
calls.length = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = new Headers(init?.headers);
calls.push({ url, headers });
return respond();
// SAFETY: the stub preserves the fetch signature; every caller in this
// file restores globalThis.fetch in afterEach.
}) as typeof fetch;
};
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('fileReferenceExists directory scoping (issue 3019)', () => {
test('sends the session directory on the stat probe', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
expect(exists).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
});
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
// Directory A resolves the workspace on the server (the browsed
// lastDirectory), so the probe for a path under B is rejected with 400
// and resolves false. The same path probed under B itself must issue a
// fresh request rather than reuse A's cached rejection.
stubFetchWith(() => {
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
if (directoryHint !== '/repo-b') {
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
}
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
});
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
expect(rejectedUnderA).toBe(false);
expect(acceptedUnderB).toBe(true);
expect(calls).toHaveLength(2);
});
test('serves a repeated probe under the same directory from the cache', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
expect(warm).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,80 @@
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};
+76
View File
@@ -3,6 +3,7 @@ import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
import { createProjectDirectoryRuntime } from '../opencode/project-directory-runtime.js';
const createRouteRegistry = () => {
const routes = new Map();
@@ -1048,3 +1049,78 @@ describe('fs list symlink path space (issue 2627)', () => {
});
}
});
describe('fs stat directory scope (issue 3019)', () => {
// Wires the real project-directory runtime so the stat route resolves the
// workspace exactly as the server does: explicit x-opencode-directory header
// first, then the settings.lastDirectory fallback. The renderer's file
// reference probes must send the header because lastDirectory reflects the
// directory the UI last browsed, not the session's directory.
const registerStatWithProjectDirectoryRuntime = () => {
const projectDirectoryRuntime = createProjectDirectoryRuntime({
fsPromises: {
stat: async (targetPath) => {
if (targetPath === '/repo-a' || targetPath === '/repo-b') {
return { isDirectory: () => true };
}
return { isDirectory: () => false, isFile: () => true, size: 12 };
},
realpath: async (targetPath) => targetPath,
},
path: { resolve: (p) => path.posix.resolve(p) },
normalizeDirectoryPath: (p) => p,
readSettingsFromDiskMigrated: async () => ({ lastDirectory: '/repo-a', projects: [] }),
getReadSettingsFromDiskMigrated: undefined,
sanitizeProjects: (input) => input,
});
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isFile: () => true, size: 12 }),
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: projectDirectoryRuntime.resolveProjectDirectory,
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/stat');
};
const callStat = async (handler, { headers = {}, query }) => {
const res = createMockResponse();
const req = {
query,
get: (name) => headers[name.toLowerCase()] ?? undefined,
};
await handler(req, res);
return res;
};
it('rejects a stat for a file under the session directory when only lastDirectory resolves the workspace', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, { query: { path: '/repo-b/src/index.ts', optional: 'true' } });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
});
it('accepts the same stat when the session directory rides the x-opencode-directory header', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, {
headers: { 'x-opencode-directory': '/repo-b' },
query: { path: '/repo-b/src/index.ts', optional: 'true' },
});
expect(res.statusCode).toBe(200);
expect(res.body.isFile).toBe(true);
});
});