From 3b3799f79e1e9a543704463324f9390c634f1796 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Fri, 14 Aug 2026 13:12:50 +0000 Subject: [PATCH] fix(web): optional fs stat outside workspace returns exists:false Graceful probe: an optional stat of a config/backup path outside the active workspace now reports exists:false instead of 400, so boot-time probes don't surface console errors for a check that was never mandatory. --- packages/web/server/lib/fs/routes.js | 8 +++ packages/web/server/lib/fs/routes.test.js | 66 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 10842ef3..cb664213 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -752,6 +752,14 @@ export const registerFsRoutes = (app, dependencies) => { openchamberUserConfigRoot, }); if (!resolved.ok) { + // An `optional` stat is a graceful probe — the caller only wants to + // know whether a path resolves to a readable file, and treats both + // "missing" and "outside the active workspace" as absent. Without this, + // boot-time probes of config/backup paths outside the workspace 400 and + // surface as console errors for a check that was never mandatory. + if (optional && resolved.error === 'Path is outside of active workspace') { + return res.json({ path: filePath, exists: false }); + } if (req.query?.allowOutsideWorkspace === 'true') { console.warn(`Rejected outside-workspace stat: ${resolved.error}`); } diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 1112e333..105f59f1 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -239,6 +239,32 @@ const callRead = async (handler, query) => { return res; }; +const registerStat = (fsPromises) => { + const { app, getRoute } = createRouteRegistry(); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + ...fsPromises, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return getRoute('GET', '/api/fs/stat'); +}; + +const callStat = async (handler, query) => { + const res = createMockResponse(); + await handler({ query }, res); + return res; +}; + const callRaw = async (handler, query) => { const res = createMockResponse(); await handler({ query }, res); @@ -458,6 +484,46 @@ describe('fs read', () => { }); }); +describe('fs stat', () => { + it('returns exists:false for an outside-workspace path when optional', async () => { + const fsPromises = { + stat: vi.fn(async () => ({ isFile: () => true, size: 3 })), + }; + const handler = registerStat(fsPromises); + + const res = await callStat(handler, { path: '/outside/plan.md', optional: 'true' }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ path: '/outside/plan.md', exists: false }); + expect(fsPromises.stat).not.toHaveBeenCalled(); + }); + + it('still rejects an outside-workspace path when not optional', async () => { + const fsPromises = { + stat: vi.fn(async () => ({ isFile: () => true, size: 3 })), + }; + const handler = registerStat(fsPromises); + + const res = await callStat(handler, { path: '/outside/plan.md' }); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Path is outside of active workspace' }); + expect(fsPromises.stat).not.toHaveBeenCalled(); + }); + + it('returns stat data for an in-workspace file', async () => { + const fsPromises = { + stat: vi.fn(async () => ({ isFile: () => true, size: 3 })), + }; + const handler = registerStat(fsPromises); + + const res = await callStat(handler, { path: '/repo/file.txt' }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ path: '/repo/file.txt', isFile: true, size: 3 }); + }); +}); + describe('fs reveal', () => { it.each([ ['linux', 'xdg-open', ['/repo']],