fix: probe bounded before a realpath in clone and New folder (release review, #516)

Clone ran an unbounded realpath over every Docker case workspace, and the mkdir
route inherited the picker resolver's realpathSync, which freezes the whole server
on a mount that stopped answering. The picker's browse route has the same call and
is left as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-04 17:29:08 +01:00
1 parent 345c901e81
commit 7803d5616a
3 files changed
+53 -1

No files matched your search

+7 -1
View File
@@ -802,8 +802,14 @@ export function registerCaseRoutes(
// with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees,
// never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read.
try {
// Probed first (#516): a case's workspace can sit on a mount that stopped answering, and an unbounded
// realpath there would hold this request and a threadpool worker. Such a root is judged as spelled.
const roots = await Promise.all(
(await defaultDockerWorkspaces()).map((root) => fs.realpath(root).catch(() => resolve(root)))
(await defaultDockerWorkspaces()).map(async (root) =>
(await probePathKind(root, { pastCap: true })) === 'unknown'
? resolve(root)
: fs.realpath(root).catch(() => resolve(root))
)
);
// A custom folder is already symlink-resolved; the default one is judged through its real cases dir.
const dest =
+9
View File
@@ -1457,6 +1457,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
// Probed first (#516): the picker's resolver runs realpathSync on this path, which would freeze the
// whole server on a mount that stopped answering.
if ((await probePathKind(parentPath, { pastCap: true })) === 'unknown') {
reply.code(422);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('The folder', parentPath, { pastCap: true })
);
}
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
@@ -0,0 +1,37 @@
/**
* @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's
* resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and
* the request never reaches a synchronous call that would freeze the server.
*
* The probe is mocked: a mount that never answers cannot be made here. Port: none (inject).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness } from './_route-test-utils.js';
describe('POST /api/filesystem/mkdir on an unresponsive mount', () => {
afterEach(() => vi.mocked(probePathKind).mockClear());
it('refuses before the picker resolver touches the path', async () => {
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const { app } = await createRouteTestHarness(registerFileRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: '/mnt/dead/projects', name: 'fresh' },
});
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/not responding|not checked/);
expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true });
});
});