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
@@ -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' });
});
}
});