From 71bae089a77c591bc2ac6f5b7b4f35afdb74758c Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Mon, 15 Jun 2026 04:00:02 -0400 Subject: [PATCH] fix: pass workspace directory in Files API requests (#1588) * fix: pass effective workspace directory in Files API requests The web Files API used useDirectoryStore.currentDirectory as the workspace root, but the FilesView's effective directory comes from useEffectiveDirectory() which can differ (e.g. worktree sessions). When they diverged the server rejected file reads with 'Path is outside of active workspace'. Add directory override to FileReadOptions so callers can pass the effective directory per-call. The FilesView now passes its root (from useEffectiveDirectory) through readFile, statFile, image/PDF URLs, and the desktop image fallback. The server receives the correct workspace root via x-opencode-directory header or directory query parameter. Fixes #1456 * fix: cover files workspace directory regressions * fix: sync directory store on draft session and forward cache options The content cache wrapper in RuntimeAPIProvider was dropping the options parameter (including the per-call directory override) when making internal statFile and readFreshFile calls during cache validation and misses. This caused the underlying web API to fall back to getDirectory() which reads useDirectoryStore.currentDirectory. Additionally, openNewSessionDraft, setNewSessionDraftTarget, and overrideNewSessionDraftTarget updated the draft's directory without ever syncing useDirectoryStore. Since the web API's getDirectory() reads from that store, it returned the stale previous-project directory during draft sessions, causing 'Path is outside of active workspace' errors when opening files. Forward options through all internal calls in the content cache wrapper, and sync useDirectoryStore via setDirectory() whenever the draft session directory changes. --------- Co-authored-by: Bohdan Triapitsyn --- .../ui/src/components/views/FilesView.tsx | 38 +++++++----- .../ui/src/contexts/RuntimeAPIProvider.tsx | 6 +- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/sync/session-ui-store.ts | 12 ++++ packages/web/src/api/files.test.ts | 60 +++++++++++++++++++ packages/web/src/api/files.ts | 32 +++++++--- packages/web/src/api/index.ts | 3 +- 7 files changed, 123 insertions(+), 29 deletions(-) create mode 100644 packages/web/src/api/files.test.ts diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 25d4bdcf..8dc39d0a 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -800,8 +800,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { () => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace, outsideFileGrant: selectedOutsideFileGrant, + directory: root || undefined, }), - [mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant], + [mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant, root], ); // Editor tabs horizontal scroll fades @@ -1478,10 +1479,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise => { - if (files.readFile) { - const result = await files.readFile(path, options); - return result.content ?? ''; - } + if (files.readFile) { + const result = await files.readFile(path, { ...(options ?? {}), directory: root || undefined }); + return result.content ?? ''; + } const params = new URLSearchParams({ path }); if (options?.allowOutsideWorkspace) { @@ -1490,11 +1491,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (options?.outsideFileGrant) { params.set('outsideFileGrant', options.outsideFileGrant); } - if (options?.optional) { - params.set('optional', 'true'); - } + if (options?.optional) { + params.set('optional', 'true'); + } + if (root) { + params.set('directory', root); + } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { - // Avoid conditional requests (304 + empty body). cache: options?.optional ? 'no-store' : 'default', }); if (!response.ok) { @@ -1502,19 +1505,19 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed')); } return response.text(); - }, [files, t]); + }, [files, root, t]); const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise => { - if (files.statFile) { - const result = await files.statFile(path, options); - return { + if (files.statFile) { + const result = await files.statFile(path, { ...(options ?? {}), directory: root || undefined }); + return { path: result.path, size: result.size, mtimeMs: result.mtimeMs, }; } return null; - }, [files]); + }, [files, root]); React.useEffect(() => { if (!root || !files.statFile || openPaths.length === 0) { @@ -1526,7 +1529,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { void Promise.all(paths.map(async (path) => { try { - const stat = await files.statFile?.(path); + const stat = await files.statFile?.(path, { directory: root || undefined }); if (!cancelled && stat && !stat.isFile) { removeOpenPathsByPrefix(root, path); } @@ -2948,6 +2951,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { path: selectedFile.path, allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, outsideFileGrant: selectedFileReadOptions.outsideFileGrant, + directory: root || undefined, }) : '')) : ''; @@ -2956,6 +2960,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { path: selectedFile.path, allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, outsideFileGrant: selectedFileReadOptions.outsideFileGrant, + directory: root || undefined, }) : ''; @@ -2997,6 +3002,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { path: selectedFile.path, allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, outsideFileGrant: selectedFileReadOptions.outsideFileGrant, + directory: root || undefined, }, }); if (!response.ok) { @@ -3042,7 +3048,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return () => { cancelled = true; }; - }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); + }, [files, isSelectedImage, isSelectedSvg, root, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); React.useEffect(() => { return () => { diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index c78f8ecf..5cb76743 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -100,20 +100,20 @@ function withContentCache(files: FilesAPI): FilesAPI { if (hit) { // Validate cached entry is still fresh if (files.statFile) { - const latest = await files.statFile(path).catch(() => { + const latest = await files.statFile(path, options).catch(() => { removeCacheEntry(path); return null; }); if (!latest || !statMatches(hit, latest)) { removeCacheEntry(path); - return readFreshFile(path); + return readFreshFile(path, options); } } touchContentLru(path); return { content: hit.content, path: hit.path }; } - return readFreshFile(path); + return readFreshFile(path, options); } : undefined; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7a648776..294c2bbe 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -597,6 +597,7 @@ export interface FileReadOptions { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean; + directory?: string; } export interface FilesAPI { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f4960e01..3cf91067 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -668,6 +668,10 @@ export const useSessionUIStore = create()((set, get) => ({ void activateConfigForDirectory(configDirectory).then(() => { useConfigStore.getState().applyDefaultModelAgentSelection() }) + + if (directory && directory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(directory) + } }, // --------------------------------------------------------------------------- @@ -706,6 +710,10 @@ export const useSessionUIStore = create()((set, get) => ({ } }) void activateConfigForDirectory(nextDirectory) + + if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(nextDirectory) + } }, setDraftPreserveDirectoryOverride: (value) => @@ -824,6 +832,10 @@ export const useSessionUIStore = create()((set, get) => ({ return { newSessionDraft: nextDraft } }) void activateConfigForDirectory(nextDirectory) + + if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) { + useDirectoryStore.getState().setDirectory(nextDirectory) + } }, resolvePendingDraftWorktreeTarget: (requestId, directory, options) => diff --git a/packages/web/src/api/files.test.ts b/packages/web/src/api/files.test.ts new file mode 100644 index 00000000..f1b963c2 --- /dev/null +++ b/packages/web/src/api/files.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; + +const runtimeFetchMock = vi.fn(); + +vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({ + runtimeFetch: runtimeFetchMock, +})); + +const toUrl = (path: string, query?: RuntimeUrlQuery): string => { + const params = query instanceof URLSearchParams ? query : new URLSearchParams(); + const queryString = params.toString(); + return queryString ? `${path}?${queryString}` : path; +}; + +const urls: RuntimeUrlResolver = { + api: toUrl, + authenticatedAsset: toUrl, + auth: toUrl, + health: (query?: RuntimeUrlQuery) => toUrl('/health', query), + rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })), + sse: toUrl, + websocket: toUrl, +}; + +describe('createWebFilesAPI', () => { + it('uses per-call workspace directory for stat and read requests', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/stale-workspace' }); + + runtimeFetchMock.mockResolvedValueOnce(Response.json({ path: '/worktree-b/file.txt', isFile: true, size: 12 })); + await api.statFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/stat?path=%2Fworktree-b%2Ffile.txt', { + headers: { 'x-opencode-directory': '/worktree-a' }, + }); + + runtimeFetchMock.mockResolvedValueOnce(new Response('content')); + await api.readFile?.('/worktree-b/file.txt', { directory: '/worktree-a' }); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/read?path=%2Fworktree-b%2Ffile.txt', { + cache: 'default', + headers: { 'x-opencode-directory': '/worktree-a' }, + }); + }); + + it('sends the workspace directory header for downloads', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/current-workspace' }); + + runtimeFetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + await expect(api.downloadFile?.('/current-workspace/file.txt')).rejects.toThrow('Download failed'); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/raw', { + query: { path: '/current-workspace/file.txt', download: true }, + headers: { 'x-opencode-directory': '/current-workspace' }, + }); + }); +}); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 3cb034e5..7e8f07e7 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -11,6 +11,7 @@ const normalizePath = (path: string): string => path.replace(/\\/g, '/'); interface WebFilesAPIOptions { urls: RuntimeUrlResolver; + getDirectory?: () => string | undefined; } type WebDirectoryEntry = { @@ -45,7 +46,12 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL }; }; -export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ +const directoryHeaders = (getDirectory?: () => string | undefined, override?: string): Record | undefined => { + const directory = override || getDirectory?.(); + return directory ? { 'x-opencode-directory': directory } : undefined; +}; + +export const createWebFilesAPI = ({ urls, getDirectory }: WebFilesAPIOptions): FilesAPI => ({ async listDirectory(path: string, options): Promise { const target = normalizePath(path); const params = new URLSearchParams(); @@ -56,7 +62,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ params.set('respectGitignore', 'true'); } - const response = await runtimeFetch(urls.api('/api/fs/list', params)); + const response = await runtimeFetch(urls.api('/api/fs/list', params), { + headers: directoryHeaders(getDirectory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -83,7 +91,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ params.set('limit', String(payload.maxResults)); } - const response = await runtimeFetch(urls.api('/api/find/file', params)); + const response = await runtimeFetch(urls.api('/api/find/file', params), { + headers: directoryHeaders(getDirectory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -103,7 +113,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/mkdir'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target }), }); @@ -128,7 +138,9 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ if (options?.outsideFileGrant) { params.set('outsideFileGrant', options.outsideFileGrant); } - const response = await runtimeFetch(urls.api('/api/fs/stat', params)); + const response = await runtimeFetch(urls.api('/api/fs/stat', params), { + headers: directoryHeaders(getDirectory, options?.directory), + }); if (!response.ok) { const error = await response.json().catch(() => ({ error: response.statusText })); @@ -158,6 +170,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ } const response = await runtimeFetch(urls.api('/api/fs/read', params), { cache: options?.optional ? 'no-store' : 'default', + headers: directoryHeaders(getDirectory, options?.directory), }); if (!response.ok) { @@ -173,7 +186,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/write'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target, content }), }); @@ -193,7 +206,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch(urls.api('/api/fs/delete'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: target }), }); @@ -209,7 +222,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> { const response = await runtimeFetch(urls.api('/api/fs/rename'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ oldPath, newPath }), }); @@ -228,7 +241,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ async revealPath(targetPath: string): Promise<{ success: boolean }> { const response = await runtimeFetch(urls.api('/api/fs/reveal'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...directoryHeaders(getDirectory) }, body: JSON.stringify({ path: normalizePath(targetPath) }), }); @@ -245,6 +258,7 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({ const target = normalizePath(path); const response = await runtimeFetch('/api/fs/raw', { query: { path: target, download: true }, + headers: directoryHeaders(getDirectory), }); if (!response.ok) { throw new Error(`Download failed (${response.status})`); diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 20398594..12831517 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -5,6 +5,7 @@ import { setRuntimeUrlResolver, type RuntimeUrlResolver, } from '@openchamber/ui/lib/runtime-url'; +import { useDirectoryStore } from '@openchamber/ui/stores/useDirectoryStore'; import { createWebTerminalAPI } from './terminal'; import { createWebGitAPI } from './git'; import { createWebFilesAPI } from './files'; @@ -39,7 +40,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' }, terminal: createWebTerminalAPI(), git: createWebGitAPI(), - files: createWebFilesAPI({ urls: activeUrls }), + files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }), settings: createWebSettingsAPI(), permissions: createWebPermissionsAPI(), notifications: createWebNotificationsAPI(),