From 7803d5616a0e6a23b85d8bbdccf8d71ad0990fdf Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 4 Nov 2026 17:29:08 +0100 Subject: [PATCH] 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 --- src/web/routes/case-routes.ts | 8 +++- src/web/routes/file-routes.ts | 9 +++++ .../filesystem-mkdir-unreachable.test.ts | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/routes/filesystem-mkdir-unreachable.test.ts diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 80cd75ab..0cfc5f5d 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -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 = diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 47327c79..95896bfd 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -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> => { 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, diff --git a/test/routes/filesystem-mkdir-unreachable.test.ts b/test/routes/filesystem-mkdir-unreachable.test.ts new file mode 100644 index 00000000..92699899 --- /dev/null +++ b/test/routes/filesystem-mkdir-unreachable.test.ts @@ -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(); + 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 }); + }); +});