fix(sessions): keep missing-worktree relocation manual

Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion.

Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop.

Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 02:10:06 +03:00
parent eb6f7b0904
commit 3132d1361a
32 changed files with 211 additions and 763 deletions
@@ -14,6 +14,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- `POST /api/fs/mkdir`
- `GET /api/fs/read`
- `GET /api/fs/raw`
- `GET /api/fs/stat`
- `GET /api/fs/directory-stat`
- `GET /api/fs/serve/:path(*)`
- `POST /api/fs/write`
- `POST /api/fs/upload`
@@ -43,6 +45,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the OpenChamber config root and the managed chats root (`managedChatsRoot` dependency; `OPENCHAMBER_CHATS_DIR` upstream, default `<config root>/chats`). Chat worktrees may legitimately live outside every project workspace.
- `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment).
- 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.
- `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown.
- Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks.
- 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.
+31
View File
@@ -864,6 +864,37 @@ export const registerFsRoutes = (app, dependencies) => {
}
});
app.get('/api/fs/directory-stat', async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const paths = new URL(req.url, 'http://openchamber.local').searchParams.getAll('path');
const directoryPath = paths.length === 1 ? paths[0].trim() : '';
if (!directoryPath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
// Directory discovery uses the same path policy as /api/fs/list, including
// paths outside the current workspace. stat follows symlinks without readdir.
const resolvedPath = path.resolve(normalizeDirectoryPath(directoryPath));
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
return res.json({ isDirectory: true });
} catch (error) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return res.status(error.code === 'ENOENT' ? 404 : 400).json({
error: error.code === 'ENOENT' ? 'Directory not found' : 'Specified path is not a directory',
reason: error.code === 'ENOENT' ? 'not-found' : 'not-directory',
});
}
if (isOsPermissionError(error)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
return res.status(500).json({ error: 'Failed to stat directory' });
}
});
app.get('/api/fs/stat', async (req, res) => {
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
const optional = req.query.optional === 'true';
+110 -1
View File
@@ -1,5 +1,9 @@
import { EventEmitter } from 'events';
import path from 'path';
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
@@ -1385,7 +1389,11 @@ describe('fs stat directory scope (issue 3019)', () => {
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isFile: () => true, size: 12 }),
stat: async (targetPath) => (
targetPath === '/repo-b'
? { isDirectory: () => true, mtimeMs: 123 }
: { isFile: () => true, size: 12, mtimeMs: 456 }
),
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
@@ -1428,6 +1436,107 @@ describe('fs stat directory scope (issue 3019)', () => {
expect(res.statusCode).toBe(200);
expect(res.body.isFile).toBe(true);
});
});
describe('fs stat directory error handling', () => {
it('loads in Node without workspace node_modules, as packaged desktop does', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-'));
try {
await mkdir(path.join(directory, 'fs'));
await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs'));
await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js'));
expect(() => execFileSync('node', [
'--input-type=module',
'--eval',
'await import(process.argv[1])',
pathToFileURL(path.join(directory, 'fs/routes.mjs')).href,
], { cwd: directory, stdio: 'pipe' })).not.toThrow();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('returns directory-missing reasons and permission errors for directory stat', async () => {
const { app, getRoute } = createRouteRegistry();
const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' });
const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' });
const eacces = Object.assign(new Error('denied'), { code: 'EACCES' });
const stat = vi.fn(async (targetPath) => {
if (targetPath === '/repo-b') throw enoent;
if (targetPath === '/repo-b/file.txt/child') throw enotdir;
if (targetPath === '/repo-b/protected') throw eacces;
if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false };
if (targetPath === '/repo-b/failure') throw new Error('unavailable');
return { isDirectory: () => true, mtimeMs: 1 };
});
const readdir = vi.fn(async () => []);
const callStat = async (handler, { headers = {}, query }) => {
const res = createMockResponse();
const req = {
url: `/api/fs/directory-stat?${new URLSearchParams(query)}`,
query,
get: (name) => headers[name.toLowerCase()] ?? undefined,
};
await handler(req, res);
return res;
};
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat,
readdir,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
const handler = getRoute('GET', '/api/fs/directory-stat');
const available = await callStat(handler, { query: { path: '/other-project' } });
expect(available.statusCode).toBe(200);
expect(available.body).toEqual({ isDirectory: true });
expect(available.getHeader('Cache-Control')).toBe('no-store');
expect(stat).toHaveBeenCalledTimes(1);
const invalid = await callStat(handler, { query: { path: ' ' } });
expect(invalid.statusCode).toBe(400);
expect(stat).toHaveBeenCalledTimes(1);
for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) {
const malformed = createMockResponse();
await handler({ url: `/api/fs/directory-stat?${query}` }, malformed);
expect(malformed.statusCode).toBe(400);
}
expect(stat).toHaveBeenCalledTimes(1);
const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } });
expect(missing.statusCode).toBe(404);
expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } });
expect(notDir.statusCode).toBe(400);
expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } });
expect(denied.statusCode).toBe(403);
expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } });
expect(file.statusCode).toBe(400);
expect(file.body.reason).toBe('not-directory');
const failure = await callStat(handler, { query: { path: '/repo-b/failure' } });
expect(failure.statusCode).toBe(500);
expect(failure.body).toEqual({ error: 'Failed to stat directory' });
expect(readdir).not.toHaveBeenCalled();
});
});
describe('fs managed chats root', () => {