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', () => {
@@ -35,7 +35,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
- Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit.
- Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution.
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own.
- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.