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:
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user