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' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user