diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 6d4665e2..29d5416d 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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>(); -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 => { - 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((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)) { diff --git a/packages/ui/src/components/chat/fileReferenceStat.test.ts b/packages/ui/src/components/chat/fileReferenceStat.test.ts new file mode 100644 index 00000000..2de4afad --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceStat.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/chat/fileReferenceStat.ts b/packages/ui/src/components/chat/fileReferenceStat.ts new file mode 100644 index 00000000..cbc7c105 --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceStat.ts @@ -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>(); +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 => { + 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((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; +}; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 64467af7..6055ce8b 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -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); + }); +});