fix(desktop): recover from macOS directory permission failures

This commit is contained in:
deatheros
2026-08-07 01:49:44 +03:00
parent 7e0e22f6e2
commit d8518bf053
30 changed files with 497 additions and 106 deletions
+1
View File
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
- Floating Mini Chat windows.
- Multiple native windows.
- Native notifications.
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
- One-click open/reveal/open-in-app actions.
- Desktop host switcher and deep-link imports.
- Local and remote instance handling.
@@ -22,6 +22,10 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
import {
isFilesystemError,
type FilesystemErrorReason,
} from '@/lib/api/files-errors';
interface DirectoryExplorerDialogProps {
open: boolean;
@@ -148,7 +152,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile } = useDeviceInfo();
const inputRef = React.useRef<HTMLInputElement>(null);
const addButtonRef = React.useRef<HTMLButtonElement>(null);
@@ -158,6 +162,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
const [browseErrorReason, setBrowseErrorReason] = React.useState<FilesystemErrorReason | null>(null);
const [browseReloadKey, setBrowseReloadKey] = React.useState(0);
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const [isConfirming, setIsConfirming] = React.useState(false);
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
@@ -250,16 +256,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
React.useEffect(() => {
if (!open || !browseDirectoryAbsolutePath) {
setEntries([]);
setBrowseErrorReason(null);
return;
}
let cancelled = false;
setIsLoading(true);
setIsBrowseDirectoryMissing(false);
setBrowseErrorReason(null);
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
.then((result) => {
if (cancelled) return;
setIsBrowseDirectoryMissing(false);
setBrowseErrorReason(null);
const nextEntries = result
.filter((entry) => entry.isDirectory)
.map((entry) => ({
@@ -269,10 +278,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
.sort((left, right) => left.name.localeCompare(right.name));
setEntries(nextEntries);
})
.catch(() => {
.catch((error) => {
if (!cancelled) {
setEntries([]);
setIsBrowseDirectoryMissing(true);
const reason = isFilesystemError(error) ? error.reason : 'unknown';
setBrowseErrorReason(reason);
setIsBrowseDirectoryMissing(reason === 'not-found');
}
})
.finally(() => {
@@ -282,7 +293,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
return () => {
cancelled = true;
};
}, [browseDirectoryAbsolutePath, open]);
}, [browseDirectoryAbsolutePath, browseReloadKey, open]);
const filteredEntries = React.useMemo(() => {
const lowerFilter = browseFilterQuery.toLowerCase();
@@ -327,12 +338,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const shouldCreateTarget = Boolean(
targetPath
&& !isAlreadyAdded
&& (browseErrorReason === null || browseErrorReason === 'not-found')
&& (
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
)
);
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
const canAddProject = !isConfirming
&& !isOpeningFinder
&& !isAlreadyAdded
&& browseErrorReason !== 'os-permission'
&& browseErrorReason !== 'invalid-response'
&& browseErrorReason !== 'unknown'
&& Boolean(targetPath);
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
const highlightedRow = rows[highlightedIndex] ?? null;
const hasHighlightedBrowseItem = Boolean(
@@ -459,7 +477,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}, [browseToDisplayPath, browseToEntry]);
const handleOpenInFinder = React.useCallback(async () => {
if (!isDesktop || isOpeningFinder) return;
if (!canRequestAccess || isOpeningFinder) return;
setIsOpeningFinder(true);
try {
const result = await requestAccess(targetPath);
@@ -488,7 +506,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
} finally {
setIsOpeningFinder(false);
}
}, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
}, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
@@ -596,6 +614,24 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<div className="py-10 text-center typography-ui-label text-muted-foreground">
{t('directoryExplorerDialog.browse.loading')}
</div>
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<div className="typography-ui-label text-[var(--status-error-foreground)]">
{browseErrorReason === 'os-permission'
? t('directoryExplorerDialog.browse.permissionDenied')
: t('directoryExplorerDialog.browse.loadFailed')}
</div>
<div className="flex items-center gap-2">
{browseErrorReason === 'os-permission' && canRequestAccess ? (
<Button size="xs" onClick={() => void handleOpenInFinder()} disabled={isOpeningFinder}>
{t('directoryExplorerDialog.browse.grantAccess')}
</Button>
) : null}
<Button variant="outline" size="xs" onClick={() => setBrowseReloadKey((key) => key + 1)}>
{t('directoryExplorerDialog.browse.retry')}
</Button>
</div>
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center typography-ui-label text-muted-foreground">
{t('directoryExplorerDialog.browse.empty')}
@@ -688,7 +724,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<>
{!isMobile ? footerHints : null}
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
{isDesktop ? (
{canRequestAccess ? (
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
</Button>
@@ -78,4 +78,5 @@
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
@@ -9,6 +9,7 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -32,6 +33,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
import {
getSessionNodesActivityState,
@@ -348,18 +350,57 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const groupPrSummary = usePrVisualSummary(groupPrKey);
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
const childStores = useChildStoreManager();
const bootstrapDirectory = normalizePath(group.directory ?? null);
const bootstrapState = React.useSyncExternalStore(
const bootstrapDirectories = React.useMemo(() => {
const directories = group.folderScopes?.map((scope) => normalizePath(scope.directory))
?? [normalizePath(group.directory ?? null)];
return [...new Set(directories.filter((directory): directory is string => Boolean(directory)))];
}, [group.directory, group.folderScopes]);
React.useSyncExternalStore(
React.useCallback(
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectory, childStores],
(notify) => bootstrapDirectories.length > 0 ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectories.length, childStores],
),
React.useCallback(
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
[bootstrapDirectory, childStores],
() => bootstrapDirectories.map((directory) => (
`${directory}\u0000${childStores.getBootstrapState(directory) ?? ''}\u0000${childStores.getBootstrapFailure(directory) ?? ''}`
)).join('\u0001'),
[bootstrapDirectories, childStores],
),
React.useCallback(() => undefined, []),
React.useCallback(() => '', []),
);
const bootstrapLoading = bootstrapDirectories.some((directory) => {
const state = childStores.getBootstrapState(directory);
return state === 'queued' || state === 'running';
});
const failedBootstrapDirectory = bootstrapDirectories.find(
(directory) => childStores.getBootstrapState(directory) === 'failed',
) ?? null;
const bootstrapFailure = failedBootstrapDirectory
? childStores.getBootstrapFailure(failedBootstrapDirectory)
: undefined;
const canGrantBootstrapAccess = bootstrapFailure === 'os-permission' && canRequestNativeDirectoryAccess();
const [isRequestingBootstrapAccess, setIsRequestingBootstrapAccess] = React.useState(false);
const retryFailedBootstrap = React.useCallback(() => {
if (!failedBootstrapDirectory) return;
childStores.requestBootstrap({
directory: failedBootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
});
}, [childStores, failedBootstrapDirectory, group.isMain, isCollapsed]);
const grantFailedBootstrapAccess = React.useCallback(async () => {
if (!failedBootstrapDirectory || !canGrantBootstrapAccess || isRequestingBootstrapAccess) return;
setIsRequestingBootstrapAccess(true);
try {
const result = await requestDirectoryAccess(failedBootstrapDirectory);
if (result.success) retryFailedBootstrap();
} finally {
setIsRequestingBootstrapAccess(false);
}
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
const maxVisible = hideDirectoryControls ? 10 : 5;
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
@@ -879,6 +920,33 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
const bootstrapFailureNotice = failedBootstrapDirectory ? (
<span className="inline-flex flex-wrap items-center gap-1.5">
{bootstrapFailure === 'os-permission'
? t('sessions.sidebar.group.empty.permissionDenied')
: t('sessions.sidebar.group.empty.loadFailed')}
{canGrantBootstrapAccess ? (
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
disabled={isRequestingBootstrapAccess}
onClick={() => void grantFailedBootstrapAccess()}
>
{t('sessions.sidebar.group.empty.grantAccess')}
</Button>
) : null}
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
onClick={retryFailedBootstrap}
>
{t('sessions.sidebar.group.empty.retry')}
</Button>
</span>
) : null;
const body = (
<SessionFolderDndScope
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
@@ -971,34 +1039,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: bootstrapState === 'queued' || bootstrapState === 'running'
: bootstrapLoading
? (
<span className="inline-flex items-center gap-1.5">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('sessions.sidebar.group.empty.loadingSessions')}
</span>
)
: bootstrapState === 'failed' && bootstrapDirectory
? (
<span className="inline-flex items-center gap-1.5">
{t('sessions.sidebar.group.empty.loadFailed')}
<button
type="button"
className="text-foreground hover:underline"
onClick={() => childStores.requestBootstrap({
directory: bootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
})}
>
{t('sessions.sidebar.group.empty.retry')}
</button>
</span>
)
: bootstrapFailureNotice
? bootstrapFailureNotice
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{totalSessions > 0 && bootstrapFailureNotice ? (
<div className="py-1 pl-[26px] text-left typography-micro text-[var(--status-error-foreground)]">
{bootstrapFailureNotice}
</div>
) : null}
{remainingCount > 0 ? (
<button
type="button"
+10 -1
View File
@@ -1,11 +1,19 @@
import { useCallback, useEffect, useState } from 'react';
import { isDesktopShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
import {
canRequestNativeDirectoryAccess,
isDesktopShell,
requestDirectoryAccess,
startAccessingDirectory,
stopAccessingDirectory,
} from '@/lib/desktop';
export const useFileSystemAccess = () => {
const [isDesktop, setIsDesktop] = useState(false);
const [canRequestAccess, setCanRequestAccess] = useState(false);
useEffect(() => {
setIsDesktop(isDesktopShell());
setCanRequestAccess(canRequestNativeDirectoryAccess());
}, []);
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
@@ -34,6 +42,7 @@ export const useFileSystemAccess = () => {
return {
isDesktop,
canRequestAccess,
requestAccess,
startAccessing,
stopAccessing
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import {
FilesystemError,
isFilesystemError,
parseFilesystemErrorReason,
} from './files-errors';
describe('FilesystemError', () => {
test('retains a stable reason and HTTP status', () => {
const error = new FilesystemError('Access denied', {
reason: 'os-permission',
status: 403,
});
expect(isFilesystemError(error)).toBe(true);
expect(error.name).toBe('FilesystemError');
expect(error.message).toBe('Access denied');
expect(error.reason).toBe('os-permission');
expect(error.status).toBe(403);
});
test('normalizes unsupported response reasons to unknown', () => {
expect(parseFilesystemErrorReason('os-permission')).toBe('os-permission');
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
expect(parseFilesystemErrorReason(undefined)).toBe('unknown');
});
});
+40
View File
@@ -0,0 +1,40 @@
export type FilesystemErrorReason =
| 'os-permission'
| 'not-found'
| 'not-directory'
| 'invalid-response'
| 'unknown';
export class FilesystemError extends Error {
readonly reason: FilesystemErrorReason;
readonly status?: number;
constructor(message: string, options: { reason?: FilesystemErrorReason; status?: number } = {}) {
super(message);
this.name = 'FilesystemError';
this.reason = options.reason ?? 'unknown';
this.status = options.status;
}
}
export const isFilesystemError = (error: unknown): error is FilesystemError => (
error instanceof FilesystemError
|| Boolean(
error
&& typeof error === 'object'
&& 'reason' in error
&& typeof (error as { reason?: unknown }).reason === 'string'
)
);
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
switch (value) {
case 'os-permission':
case 'not-found':
case 'not-directory':
case 'invalid-response':
return value;
default:
return 'unknown';
}
};
+7 -2
View File
@@ -531,6 +531,10 @@ export const isDesktopShell = (): boolean => {
return isElectronShell();
};
export const canRequestNativeDirectoryAccess = (): boolean => (
isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive()
);
export const startDesktopWindowDrag = async (): Promise<boolean> => {
if (!isDesktopShell()) {
return false;
@@ -586,12 +590,13 @@ export const requestDirectoryAccess = async (
directoryPath: string
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
// Desktop shell on local instance: use native folder picker.
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
if (canRequestNativeDirectoryAccess()) {
try {
const selected = await getDesktopBridge()?.openDialog?.({
directory: true,
multiple: false,
title: 'Select Working Directory',
...(directoryPath ? { defaultPath: directoryPath } : {}),
});
if (!selected || typeof selected !== 'string') {
return { success: false, error: 'Directory selection cancelled' };
@@ -603,7 +608,7 @@ export const requestDirectoryAccess = async (
}
}
return { success: true, path: directoryPath };
return { success: false, error: 'Native directory picker not available' };
};
const isDesktopFileGrantResult = (
+6
View File
@@ -1499,6 +1499,10 @@ export const dict = {
'directoryExplorerDialog.browse.directories': 'Verzeichnisse',
'directoryExplorerDialog.browse.loading': 'Lade Verzeichnisse...',
'directoryExplorerDialog.browse.empty': 'Keine passenden Verzeichnisse.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber benötigt Zugriff auf diesen Ordner.',
'directoryExplorerDialog.browse.loadFailed': 'Dieser Ordner konnte nicht geladen werden.',
'directoryExplorerDialog.browse.grantAccess': 'Zugriff gewähren',
'directoryExplorerDialog.browse.retry': 'Erneut versuchen',
'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis',
'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt',
'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen',
@@ -2924,6 +2928,8 @@ export const dict = {
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
'sessions.sidebar.group.empty.retry': 'Erneut versuchen',
'sessions.sidebar.group.empty.permissionDenied': 'Ordnerzugriff ist erforderlich.',
'sessions.sidebar.group.empty.grantAccess': 'Zugriff gewähren',
'chat.messageBody.actions.pinContext': 'Kontext anheften',
'chat.messageBody.actions.unpinContext': 'Kontext lösen',
'chat.messageBody.actions.contextPinFailed': 'Kontext konnte nicht angeheftet werden',
+6
View File
@@ -1656,6 +1656,10 @@ export const dict = {
'directoryExplorerDialog.browse.directories': 'Directories',
'directoryExplorerDialog.browse.loading': 'Loading directories...',
'directoryExplorerDialog.browse.empty': 'No matching directories.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber needs access to this folder.',
'directoryExplorerDialog.browse.loadFailed': 'Could not load this folder.',
'directoryExplorerDialog.browse.grantAccess': 'Grant access',
'directoryExplorerDialog.browse.retry': 'Try again',
'directoryExplorerDialog.browse.parentDirectory': 'Parent directory',
'directoryExplorerDialog.browse.addedBadge': 'Added',
'directoryExplorerDialog.browse.quickAdd': 'Add',
@@ -2007,6 +2011,8 @@ export const dict = {
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
'sessions.sidebar.group.empty.retry': 'Try again',
'sessions.sidebar.group.empty.permissionDenied': 'Folder access is required.',
'sessions.sidebar.group.empty.grantAccess': 'Grant access',
'chat.unifiedControls.title': 'Controls',
'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'No recent models',
+6
View File
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.directories": "Directorios",
"directoryExplorerDialog.browse.loading": "Cargando directorios...",
"directoryExplorerDialog.browse.empty": "No hay directorios coincidentes.",
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber necesita acceso a esta carpeta.",
"directoryExplorerDialog.browse.loadFailed": "No se pudo cargar esta carpeta.",
"directoryExplorerDialog.browse.grantAccess": "Permitir acceso",
"directoryExplorerDialog.browse.retry": "Reintentar",
"directoryExplorerDialog.browse.parentDirectory": "Directorio padre",
"directoryExplorerDialog.browse.addedBadge": "Añadido",
"directoryExplorerDialog.browse.quickAdd": "Añadir",
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
"sessions.sidebar.group.empty.retry": "Reintentar",
"sessions.sidebar.group.empty.permissionDenied": "Se requiere acceso a la carpeta.",
"sessions.sidebar.group.empty.grantAccess": "Permitir acceso",
"chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
+6
View File
@@ -1469,6 +1469,10 @@ export const dict = {
'directoryExplorerDialog.browse.directories': 'Annuaires',
'directoryExplorerDialog.browse.loading': 'Chargement des répertoires...',
'directoryExplorerDialog.browse.empty': 'Aucun répertoire correspondant.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber doit accéder à ce dossier.',
'directoryExplorerDialog.browse.loadFailed': 'Impossible de charger ce dossier.',
'directoryExplorerDialog.browse.grantAccess': 'Autoriser laccès',
'directoryExplorerDialog.browse.retry': 'Réessayer',
'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent',
'directoryExplorerDialog.browse.addedBadge': 'Ajouté',
'directoryExplorerDialog.browse.quickAdd': 'Ajouter',
@@ -1794,6 +1798,8 @@ export const dict = {
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Impossible dactualiser les sessions.',
'sessions.sidebar.group.empty.retry': 'Réessayer',
'sessions.sidebar.group.empty.permissionDenied': 'Laccès au dossier est requis.',
'sessions.sidebar.group.empty.grantAccess': 'Autoriser laccès',
'chat.unifiedControls.title': 'Contrôles',
'chat.unifiedControls.model.title': 'Modèle',
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
+6
View File
@@ -1652,6 +1652,10 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.directories': 'ディレクトリ',
'directoryExplorerDialog.browse.loading': 'ディレクトリを読み込み中...',
'directoryExplorerDialog.browse.empty': '一致するディレクトリがありません。',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber がこのフォルダにアクセスする必要があります。',
'directoryExplorerDialog.browse.loadFailed': 'このフォルダを読み込めませんでした。',
'directoryExplorerDialog.browse.grantAccess': 'アクセスを許可',
'directoryExplorerDialog.browse.retry': '再試行',
'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ',
'directoryExplorerDialog.browse.addedBadge': '追加済み',
'directoryExplorerDialog.browse.quickAdd': '追加',
@@ -2003,6 +2007,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
'sessions.sidebar.group.empty.retry': '再試行',
'sessions.sidebar.group.empty.permissionDenied': 'フォルダへのアクセスが必要です。',
'sessions.sidebar.group.empty.grantAccess': 'アクセスを許可',
'chat.unifiedControls.title': 'コントロール',
'chat.unifiedControls.model.title': 'モデル',
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
+6
View File
@@ -1658,6 +1658,10 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.directories': '디렉터리',
'directoryExplorerDialog.browse.loading': '디렉터리 로드 중...',
'directoryExplorerDialog.browse.empty': '일치하는 디렉터리가 없습니다.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber에서 이 폴더에 접근해야 합니다.',
'directoryExplorerDialog.browse.loadFailed': '이 폴더를 불러올 수 없습니다.',
'directoryExplorerDialog.browse.grantAccess': '접근 허용',
'directoryExplorerDialog.browse.retry': '다시 시도',
'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리',
'directoryExplorerDialog.browse.addedBadge': '추가됨',
'directoryExplorerDialog.browse.quickAdd': '추가',
@@ -2009,6 +2013,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
'sessions.sidebar.group.empty.retry': '다시 시도',
'sessions.sidebar.group.empty.permissionDenied': '폴더 접근이 필요합니다.',
'sessions.sidebar.group.empty.grantAccess': '접근 허용',
'chat.unifiedControls.title': '컨트롤',
'chat.unifiedControls.model.title': '모델',
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
+6
View File
@@ -811,6 +811,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
'sessions.sidebar.group.empty.permissionDenied': 'Wymagany jest dostęp do folderu.',
'sessions.sidebar.group.empty.grantAccess': 'Przyznaj dostęp',
'chat.unifiedControls.title': 'Kontrolki',
'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
@@ -1738,6 +1740,10 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.quickAdd': 'Dodaj',
'directoryExplorerDialog.browse.directories': 'Katalogi',
'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber potrzebuje dostępu do tego folderu.',
'directoryExplorerDialog.browse.loadFailed': 'Nie udało się wczytać tego folderu.',
'directoryExplorerDialog.browse.grantAccess': 'Przyznaj dostęp',
'directoryExplorerDialog.browse.retry': 'Spróbuj ponownie',
'directoryExplorerDialog.browse.loading': 'Ładowanie katalogów...',
'directoryExplorerDialog.browse.parentDirectory': 'Katalog nadrzędny',
'directoryExplorerDialog.description': 'Wybierz folder, który chcesz dodać jako projekt.',
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.directories": "Diretórios",
"directoryExplorerDialog.browse.loading": "Carregando diretórios...",
"directoryExplorerDialog.browse.empty": "Nenhum diretório correspondente.",
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber precisa acessar esta pasta.",
"directoryExplorerDialog.browse.loadFailed": "Não foi possível carregar esta pasta.",
"directoryExplorerDialog.browse.grantAccess": "Conceder acesso",
"directoryExplorerDialog.browse.retry": "Tentar novamente",
"directoryExplorerDialog.browse.parentDirectory": "Diretório pai",
"directoryExplorerDialog.browse.addedBadge": "Adicionado",
"directoryExplorerDialog.browse.quickAdd": "Adicionar",
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
"sessions.sidebar.group.empty.retry": "Tentar novamente",
"sessions.sidebar.group.empty.permissionDenied": "É necessário acesso à pasta.",
"sessions.sidebar.group.empty.grantAccess": "Conceder acesso",
"chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
+6
View File
@@ -1634,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
"directoryExplorerDialog.browse.directories": "Каталоги",
"directoryExplorerDialog.browse.loading": "Завантаження каталогів...",
"directoryExplorerDialog.browse.empty": "Немає відповідних каталогів.",
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber потрібен доступ до цієї папки.",
"directoryExplorerDialog.browse.loadFailed": "Не вдалося завантажити цю папку.",
"directoryExplorerDialog.browse.grantAccess": "Надати доступ",
"directoryExplorerDialog.browse.retry": "Спробувати знову",
"directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог",
"directoryExplorerDialog.browse.addedBadge": "Додано",
"directoryExplorerDialog.browse.quickAdd": "Додати",
@@ -1985,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
"sessions.sidebar.group.empty.retry": "Спробувати знову",
"sessions.sidebar.group.empty.permissionDenied": "Потрібен доступ до папки.",
"sessions.sidebar.group.empty.grantAccess": "Надати доступ",
"chat.unifiedControls.title": "Елементи керування",
"chat.unifiedControls.model.title": "Модель",
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
@@ -1622,6 +1622,10 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.directories': '目录',
'directoryExplorerDialog.browse.loading': '正在加载目录...',
'directoryExplorerDialog.browse.empty': '没有匹配的目录。',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber 需要访问此文件夹。',
'directoryExplorerDialog.browse.loadFailed': '无法加载此文件夹。',
'directoryExplorerDialog.browse.grantAccess': '授予访问权限',
'directoryExplorerDialog.browse.retry': '重试',
'directoryExplorerDialog.browse.parentDirectory': '上级目录',
'directoryExplorerDialog.browse.addedBadge': '已添加',
'directoryExplorerDialog.browse.quickAdd': '添加',
@@ -1973,6 +1977,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
'sessions.sidebar.group.empty.retry': '重试',
'sessions.sidebar.group.empty.permissionDenied': '需要文件夹访问权限。',
'sessions.sidebar.group.empty.grantAccess': '授予访问权限',
'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
@@ -1626,6 +1626,10 @@ export const dict: Record<I18nKey, string> = {
'directoryExplorerDialog.browse.directories': '目錄',
'directoryExplorerDialog.browse.loading': '正在載入目錄...',
'directoryExplorerDialog.browse.empty': '沒有符合的目錄。',
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber 需要存取此資料夾。',
'directoryExplorerDialog.browse.loadFailed': '無法載入此資料夾。',
'directoryExplorerDialog.browse.grantAccess': '授予存取權限',
'directoryExplorerDialog.browse.retry': '再試一次',
'directoryExplorerDialog.browse.parentDirectory': '上層目錄',
'directoryExplorerDialog.browse.addedBadge': '已新增',
'directoryExplorerDialog.browse.quickAdd': '添加',
@@ -1977,6 +1981,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
'sessions.sidebar.group.empty.retry': '再試一次',
'sessions.sidebar.group.empty.permissionDenied': '需要資料夾存取權限。',
'sessions.sidebar.group.empty.grantAccess': '授予存取權限',
'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
+48 -45
View File
@@ -13,6 +13,7 @@ import type {
FilePartInput,
} from "@opencode-ai/sdk/v2";
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
import { FilesystemError, parseFilesystemErrorReason } from "@/lib/api/files-errors";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
@@ -1751,20 +1752,55 @@ class OpencodeService {
}
const task = (async () => {
const desktopFiles = getDesktopFilesApi();
if (desktopFiles) {
const desktopFiles = getDesktopFilesApi();
try {
const result = await desktopFiles.listDirectory(directoryPath || '', options);
if (!result || !Array.isArray(result.entries)) {
return [];
if (desktopFiles) {
const result = await desktopFiles.listDirectory(directoryPath || '', options);
if (!result || !Array.isArray(result.entries)) {
throw new FilesystemError('Directory listing returned an invalid response', {
reason: 'invalid-response',
});
}
const entries = result.entries.map<FilesystemEntry>((entry) => ({
name: entry.name,
path: normalizeFsPath(entry.path),
isDirectory: !!entry.isDirectory,
isFile: !entry.isDirectory,
isSymbolicLink: false,
}));
this.listDirectoryCache.set(cacheKey, {
entries,
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
});
return entries;
}
const entries = result.entries.map<FilesystemEntry>((entry) => ({
name: entry.name,
path: normalizeFsPath(entry.path),
isDirectory: !!entry.isDirectory,
isFile: !entry.isDirectory,
isSymbolicLink: false,
}));
const params = new URLSearchParams();
if (directoryPath && directoryPath.trim().length > 0) {
params.set('path', directoryPath);
}
if (options?.respectGitignore) {
params.set('respectGitignore', 'true');
}
const query = params.toString();
const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = typeof error.error === 'string' ? error.error : 'Failed to list directory';
throw new FilesystemError(message, {
reason: parseFilesystemErrorReason((error as { reason?: unknown }).reason),
status: response.status,
});
}
const result = await response.json();
if (!result || !Array.isArray(result.entries)) {
throw new FilesystemError('Directory listing returned an invalid response', {
reason: 'invalid-response',
});
}
const entries = result.entries as FilesystemEntry[];
this.listDirectoryCache.set(cacheKey, {
entries,
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
@@ -1774,39 +1810,6 @@ class OpencodeService {
console.error('Failed to list directory contents:', error);
throw error;
}
}
try {
const params = new URLSearchParams();
if (directoryPath && directoryPath.trim().length > 0) {
params.set('path', directoryPath);
}
if (options?.respectGitignore) {
params.set('respectGitignore', 'true');
}
const query = params.toString();
const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = typeof error.error === 'string' ? error.error : 'Failed to list directory';
throw new Error(message);
}
const result = await response.json();
if (!result || !Array.isArray(result.entries)) {
return [];
}
const entries = result.entries as FilesystemEntry[];
this.listDirectoryCache.set(cacheKey, {
entries,
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
});
return entries;
} catch (error) {
console.error('Failed to list directory contents:', error);
throw error;
}
})();
const trackedTask = task.finally(() => {
+1
View File
@@ -75,6 +75,7 @@ The composer compares normalized attachment MIME types with the selected model's
- A mounted directory-store consumer pins that store for its lifetime. Eviction may dispose only unmounted directories, so optimistic actions and realtime events cannot move to a replacement store while visible React consumers remain subscribed to an older identity.
- Reconfiguration and runtime switching invalidate stale generations. A stale completion must not publish state into the new runtime.
- Failure is recorded as `failed`; it is not converted into a successful empty snapshot. Forced demand can retry failed or completed work.
- A failed bootstrap is classified as `os-permission` only when the owning runtime filesystem API independently confirms `EPERM`/`EACCES` for that exact directory. OpenCode/proxy error text is never used as permission evidence. The scheduler retains the directory-scoped reason so local Desktop can offer native folder selection before a forced retry.
Bootstrap remains stale-while-revalidate: a directory store may paint persisted sessions immediately, but only a successful authoritative fetch may replace that cached list.
+35
View File
@@ -13,6 +13,7 @@ import {
setSyncPerformanceDiagnosticsEnabled,
} from './performance-diagnostics';
import { DIR_IDLE_TTL_MS } from './types';
import { FilesystemError } from '@/lib/api/files-errors';
const deferred = () => {
let resolve!: () => void;
@@ -414,6 +415,40 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
manager.disposeAll();
});
test('records os-permission failures and clears them on forced retry', async () => {
const manager = new ChildStoreManager();
let denied = true;
const cleanup = manager.configure({
onBootstrap: () => {
if (denied) {
throw new FilesystemError('Access denied', { reason: 'os-permission', status: 403 });
}
},
});
manager.requestBootstrap({ directory: '/protected', priority: 'selected', reason: 'current-directory' });
await settle();
await settle();
expect(manager.getBootstrapState('/protected')).toBe('failed');
expect(manager.getBootstrapFailure('/protected')).toBe('os-permission');
denied = false;
manager.requestBootstrap({
directory: '/protected',
priority: 'selected',
reason: 'action-demand',
force: true,
});
await settle();
await settle();
expect(manager.getBootstrapState('/protected')).toBe('complete');
expect(manager.getBootstrapFailure('/protected')).toBe(undefined);
cleanup();
manager.disposeAll();
});
test('continues after a synchronous bootstrap failure', async () => {
const manager = new ChildStoreManager();
const started: string[] = [];
+17 -1
View File
@@ -6,6 +6,7 @@ import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessi
import { normalizePath } from "@/lib/pathNormalization"
import { startSessionLoadPerformanceEvent } from "./session-load-performance"
import { countSyncPerformance } from "./performance-diagnostics"
import { isFilesystemError } from "@/lib/api/files-errors"
export type DirectoryStore = State & {
/** Apply a partial state update */
@@ -216,6 +217,7 @@ export type DirectoryBootstrapDemand = {
}
export type DirectoryBootstrapState = "queued" | "running" | "complete" | "failed"
export type DirectoryBootstrapFailureReason = "os-permission" | "generic"
export type DirectoryBootstrapContext = DirectoryBootstrapDemand & {
generation: number
@@ -296,6 +298,7 @@ export class ChildStoreManager {
private readonly bootstrapQueue = new Map<string, QueuedBootstrap>()
private readonly runningBootstraps = new Map<string, RunningBootstrap>()
private readonly bootstrapStates = new Map<string, DirectoryBootstrapState>()
private readonly bootstrapFailures = new Map<string, DirectoryBootstrapFailureReason>()
private onBootstrap?: (context: DirectoryBootstrapContext) => Promise<void> | void
private onDispose?: (directory: string) => void
@@ -479,6 +482,11 @@ export class ChildStoreManager {
return normalizedDirectory ? this.bootstrapStates.get(normalizedDirectory) : undefined
}
getBootstrapFailure(directory: string): DirectoryBootstrapFailureReason | undefined {
const normalizedDirectory = normalizePath(directory)
return normalizedDirectory ? this.bootstrapFailures.get(normalizedDirectory) : undefined
}
subscribeBootstrap(listener: () => void): () => void {
this.bootstrapSubscribers.add(listener)
return () => this.bootstrapSubscribers.delete(listener)
@@ -530,6 +538,7 @@ export class ChildStoreManager {
if (demand.force) running.rerunRequested = true
return false
}
this.bootstrapFailures.delete(directory)
const existing = this.bootstrapQueue.get(directory)
const next: QueuedBootstrap = existing
? {
@@ -603,14 +612,19 @@ export class ChildStoreManager {
.then(() => {
if (isCurrent()) {
this.bootstrapStates.set(next.directory, "complete")
this.bootstrapFailures.delete(next.directory)
finishPerformanceEvent("complete")
} else {
finishPerformanceEvent("stale")
}
})
.catch(() => {
.catch((error) => {
if (isCurrent()) {
this.bootstrapStates.set(next.directory, "failed")
this.bootstrapFailures.set(
next.directory,
isFilesystemError(error) && error.reason === "os-permission" ? "os-permission" : "generic",
)
finishPerformanceEvent("error")
} else {
finishPerformanceEvent("stale")
@@ -658,6 +672,7 @@ export class ChildStoreManager {
this.bootstrapQueue.delete(directory)
this.manualBootstrapDemands.delete(directory)
this.bootstrapStates.delete(directory)
this.bootstrapFailures.delete(directory)
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
this.children.delete(directory)
this.notifyRegistrySubscribers()
@@ -719,6 +734,7 @@ export class ChildStoreManager {
this.bootstrapQueue.clear()
this.runningBootstraps.clear()
this.bootstrapStates.clear()
this.bootstrapFailures.clear()
this.bootstrapDemandsByOwner.clear()
this.manualBootstrapDemands.clear()
this.notifyBootstrapSubscribers()
+16 -1
View File
@@ -68,6 +68,7 @@ import { getPermissionToastKey, showPermissionNeededToast } from "./permission-t
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -2068,7 +2069,21 @@ export function SyncProvider(props: {
}
const result = await runBootstrap(0)
if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`)
if (result === "failed") {
// OpenCode can mask the underlying errno while initializing an
// inaccessible workspace. Probe the exact directory through the
// owning runtime filesystem API so only an authoritative local
// EPERM/EACCES becomes an actionable grant-access failure.
const files = getRegisteredRuntimeAPIs()?.files
if (files) {
try {
await files.listDirectory(directory)
} catch (error) {
if (isFilesystemError(error) && error.reason === "os-permission") throw error
}
}
throw new Error(`Directory bootstrap failed for ${directory}`)
}
// Selecting a session whose directory this client had not indexed yet
// routes it through the active directory as a documented guess. This is
@@ -34,5 +34,6 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
## Notes for contributors
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
- Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
- `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks.
+34 -18
View File
@@ -16,6 +16,16 @@ const pruneOutsideFileGrants = () => {
}
};
const isOsPermissionError = (error) => (
error
&& typeof error === 'object'
&& (error.code === 'EACCES' || error.code === 'EPERM')
);
const sendOsPermissionDenied = (res, message) => (
res.status(403).json({ error: message, reason: 'os-permission' })
);
export const mintOutsideFileGrant = async (targetPath, {
scopes = ['stat', 'read', 'raw'],
fsPromises = nodeFsPromises,
@@ -583,6 +593,9 @@ export const registerFsRoutes = (app, dependencies) => {
await fsPromises.mkdir(resolvedPath, { recursive: true });
return res.json({ success: true, path: resolvedPath });
} catch (error) {
if (isOsPermissionError(error)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to create directory:', error);
return res.status(500).json({ error: error.message || 'Failed to create directory' });
}
@@ -746,8 +759,8 @@ export const registerFsRoutes = (app, dependencies) => {
}
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to stat file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' });
@@ -818,8 +831,8 @@ export const registerFsRoutes = (app, dependencies) => {
}
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to read file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
@@ -903,8 +916,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to read raw file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
@@ -964,8 +977,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to file denied');
}
console.error('Failed to serve file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to serve file' });
@@ -1026,8 +1039,8 @@ export const registerFsRoutes = (app, dependencies) => {
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to write file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
@@ -1061,8 +1074,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File or directory not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to delete path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
@@ -1116,8 +1129,8 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Source path not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access denied');
}
console.error('Failed to rename path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
@@ -1173,6 +1186,9 @@ export const registerFsRoutes = (app, dependencies) => {
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Path not found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to path denied');
}
console.error('Failed to reveal path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
}
@@ -1315,7 +1331,7 @@ export const registerFsRoutes = (app, dependencies) => {
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory' });
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
@@ -1416,10 +1432,10 @@ export const registerFsRoutes = (app, dependencies) => {
if (isPlansPath) {
return res.json({ path: requestedPath || resolvedPath || rawPath, entries: [] });
}
return res.status(404).json({ error: 'Directory not found' });
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
+15
View File
@@ -709,4 +709,19 @@ describe('fs list symlink path space (issue 2627)', () => {
]);
expect(fsPromises.readdir).toHaveBeenCalledWith('/real/pkg', { withFileTypes: true });
});
for (const code of ['EACCES', 'EPERM']) {
it(`maps ${code} to the os-permission contract`, async () => {
const error = Object.assign(new Error('denied'), { code });
const handler = registerList({
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => { throw error; }),
});
const res = await callList(handler, { path: '/workspace/protected' });
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
});
}
});
+28
View File
@@ -25,6 +25,34 @@ const urls: RuntimeUrlResolver = {
};
describe('createWebFilesAPI', () => {
it('preserves the directory permission failure contract', async () => {
const { createWebFilesAPI } = await import('./files');
const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' });
runtimeFetchMock.mockResolvedValueOnce(Response.json(
{ error: 'Access to directory denied', reason: 'os-permission' },
{ status: 403 },
));
const error = await api.listDirectory('/protected').catch((caught) => caught);
expect(error).toMatchObject({
name: 'FilesystemError',
reason: 'os-permission',
status: 403,
message: 'Access to directory denied',
});
});
it('rejects malformed successful directory listings', async () => {
const { createWebFilesAPI } = await import('./files');
const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' });
runtimeFetchMock.mockResolvedValueOnce(Response.json({ path: '/workspace' }));
await expect(api.listDirectory('/workspace')).rejects.toMatchObject({
reason: 'invalid-response',
});
});
it('uses per-call workspace directory for stat and read requests', async () => {
const { createWebFilesAPI } = await import('./files');
const api = createWebFilesAPI({ urls, getDirectory: () => '/stale-workspace' });
+21 -4
View File
@@ -4,6 +4,10 @@ import type {
FileSearchResult,
FilesAPI,
} from '@openchamber/ui/lib/api/types';
import {
FilesystemError,
parseFilesystemErrorReason,
} from '@openchamber/ui/lib/api/files-errors';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
@@ -28,12 +32,16 @@ type WebDirectoryListResponse = {
};
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
if (!payload || !Array.isArray(payload.entries)) {
throw new FilesystemError('Directory listing returned an invalid response', {
reason: 'invalid-response',
});
}
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
return {
directory,
entries: entries
entries: payload.entries
.filter((entry): entry is Required<Pick<WebDirectoryEntry, 'name' | 'path'>> & { isDirectory?: boolean } =>
Boolean(entry && typeof entry.name === 'string' && typeof entry.path === 'string')
)
@@ -67,8 +75,17 @@ export const createWebFilesAPI = ({ getDirectory }: WebFilesAPIOptions): FilesAP
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to list directory');
const error = await response.json().catch(() => ({ error: response.statusText })) as {
error?: string;
reason?: unknown;
};
throw new FilesystemError(
error.error || 'Failed to list directory',
{
reason: parseFilesystemErrorReason(error.reason),
status: response.status,
},
);
}
const result = (await response.json()) as WebDirectoryListResponse;
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
resolve: {
alias: {
'bun:test': fileURLToPath(new URL('./test/bun-test-shim.ts', import.meta.url)),
'@openchamber/ui': fileURLToPath(new URL('../ui/src', import.meta.url)),
},
},
});