fix(desktop): recover from macOS directory permission failures
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user