feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR (#3135)
* feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR
Projectless-chat worktrees were hard-pinned to
<home>/.config/openchamber/chats: the UI joined the path client-side,
workspace checks allowed only the config root, and identification matched
the literal path segment. When the OpenCode server runs as a separate
user (UID-separated setups), that root is unreachable — every chat
session answered HTTP 500 (EACCES on the session directory).
The server now owns the chats root. OPENCHAMBER_CHATS_DIR relocates it
(default unchanged: <config root>/chats); /api/fs/home answers
{ home, chatsRoot }; fs workspace checks accept the managed chats root
next to the config root; the client resolves the root from the server
(per-runtime cached, warmed at bootstrap so sync classification sees it)
and falls back to the home join for older servers.
Refs #3130
* chore: trim added comments to local precedent
* fix: forward managedChatsRoot through feature-routes-runtime to registerFsRoutes
* fix(chats): await the root warm-up and keep the legacy chats root owned
Review feedback on #3135:
- bootstrapGlobal now awaits warmChatsRootDirectory, so synchronous
session classification never sees an empty root cache (relocated
sessions were grouped as project sessions when the session list
outran /api/fs/home).
- managedProjectRoots keeps the legacy <config root>/chats entry next to
OPENCHAMBER_CHATS_DIR, so memory ownership of existing chats survives
relocation.
* fix(chats): distinguish chats-root fetch failure from older servers
* fix(sync): rehydrate managed chat sessions after the chats root warms
* fix(fs): pass managed roots through the symlink and git-dirs path checks after the main merge
* docs: drop changelog edits; changelog is the maintainer's release-time work
* fix(chats): keep legacy chat directories deletable while the root is relocated
* fix(chats): resolve roots before cleanup and initial session loads
* test(chats): type runtime spies against actual SDK contracts
---------
Signed-off-by: Steffen Mächtel <info@steffen-maechtel.de>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1d6b15bc04
commit
3df97908fe
@@ -269,6 +269,11 @@ const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProje
|
||||
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
|
||||
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
|
||||
const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects');
|
||||
// OPENCHAMBER_CHATS_DIR relocates managed chat worktrees — needed when the
|
||||
// OpenCode server runs as a separate user that cannot traverse $HOME.
|
||||
const OPENCHAMBER_CHATS_DIR = process.env.OPENCHAMBER_CHATS_DIR && process.env.OPENCHAMBER_CHATS_DIR.trim()
|
||||
? path.resolve(process.env.OPENCHAMBER_CHATS_DIR.trim())
|
||||
: path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats');
|
||||
|
||||
const MAX_THEME_JSON_BYTES = 512 * 1024;
|
||||
|
||||
@@ -1315,7 +1320,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
|
||||
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
|
||||
},
|
||||
resolvePrimaryWorktreeRoot,
|
||||
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
|
||||
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1903,6 +1908,7 @@ async function main(options = {}) {
|
||||
createFsSearchRuntime: createFsSearchRuntimeFactory,
|
||||
openchamberDataDir: OPENCHAMBER_DATA_DIR,
|
||||
openchamberUserConfigRoot: OPENCHAMBER_USER_CONFIG_ROOT,
|
||||
managedChatsRoot: OPENCHAMBER_CHATS_DIR,
|
||||
normalizeDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
|
||||
@@ -40,6 +40,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
|
||||
## Notes for contributors
|
||||
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -196,7 +196,7 @@ const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
@@ -209,8 +209,12 @@ const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDi
|
||||
return { ok: true, base: resolvedBase, resolved };
|
||||
}
|
||||
|
||||
if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
|
||||
return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved };
|
||||
// Managed roots (config root, relocated chats root) stay valid targets
|
||||
// even outside the active workspace.
|
||||
for (const root of managedRoots) {
|
||||
if (isPathWithinRoot(resolved, root, path, os)) {
|
||||
return { ok: true, base: path.resolve(root), resolved };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: 'Path is outside of active workspace' };
|
||||
@@ -249,7 +253,7 @@ const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, pa
|
||||
return { ok: false, error: 'Path is outside of active workspace' };
|
||||
};
|
||||
|
||||
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, managedRoots }) => {
|
||||
const resolvedProject = await resolveProjectDirectory(req);
|
||||
if (!resolvedProject.directory) {
|
||||
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
|
||||
@@ -261,7 +265,7 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
|
||||
return resolved;
|
||||
@@ -281,7 +285,7 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (lexical.ok) {
|
||||
return lexical;
|
||||
@@ -412,7 +416,7 @@ const escapeCloneSshKeyPath = (sshKeyPath) => {
|
||||
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
||||
};
|
||||
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, managedRoots }) => {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
@@ -434,7 +438,7 @@ const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProje
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -520,7 +524,11 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
buildAugmentedPath,
|
||||
resolveGitBinaryForSpawn,
|
||||
openchamberUserConfigRoot,
|
||||
managedChatsRoot,
|
||||
} = dependencies;
|
||||
const managedRoots = [openchamberUserConfigRoot, managedChatsRoot]
|
||||
.filter((root) => typeof root === 'string' && root.trim().length > 0)
|
||||
.map((root) => path.resolve(root));
|
||||
const realpathCache = createRealpathCache({
|
||||
realpath: fsPromises.realpath.bind(fsPromises),
|
||||
});
|
||||
@@ -699,7 +707,10 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
if (!home || typeof home !== 'string' || home.length === 0) {
|
||||
return res.status(500).json({ error: 'Failed to resolve home directory' });
|
||||
}
|
||||
return res.json({ home });
|
||||
const chatsRoot = managedChatsRoot && managedChatsRoot.trim()
|
||||
? path.resolve(managedChatsRoot.trim())
|
||||
: path.join(openchamberUserConfigRoot, 'chats');
|
||||
return res.json({ home, chatsRoot });
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve home directory:', error);
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
|
||||
@@ -725,7 +736,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
@@ -870,7 +881,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
@@ -920,7 +931,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
@@ -984,7 +995,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
@@ -1065,7 +1076,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
@@ -1117,7 +1128,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
@@ -1187,7 +1198,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
@@ -1294,7 +1305,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
@@ -1332,7 +1343,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolvedOld.ok) {
|
||||
return res.status(400).json({ error: resolvedOld.error });
|
||||
@@ -1345,7 +1356,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolvedNew.ok) {
|
||||
return res.status(400).json({ error: resolvedNew.error });
|
||||
@@ -1451,7 +1462,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolvedForWorkspace.ok) {
|
||||
console.warn(`Rejected /api/fs/exec outside workspace: ${resolvedForWorkspace.error}`);
|
||||
@@ -1689,7 +1700,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
managedRoots,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
|
||||
@@ -1429,3 +1429,80 @@ describe('fs stat directory scope (issue 3019)', () => {
|
||||
expect(res.body.isFile).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs managed chats root', () => {
|
||||
const registerWithChatsRoot = ({ managedChatsRoot, fsPromises = {} } = {}) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
mkdir: async () => undefined,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config/openchamber',
|
||||
managedChatsRoot,
|
||||
});
|
||||
return {
|
||||
home: getRoute('GET', '/api/fs/home'),
|
||||
mkdir: getRoute('POST', '/api/fs/mkdir'),
|
||||
};
|
||||
};
|
||||
|
||||
it('exposes the default chats root next to the home directory', async () => {
|
||||
const { home } = registerWithChatsRoot();
|
||||
|
||||
const res = createMockResponse();
|
||||
await home(undefined, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.home).toBe('/home/user');
|
||||
expect(res.body.chatsRoot).toBe('/home/user/.config/openchamber/chats');
|
||||
});
|
||||
|
||||
it('exposes a relocated chats root when OPENCHAMBER_CHATS_DIR is configured upstream', async () => {
|
||||
const { home } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
|
||||
|
||||
const res = createMockResponse();
|
||||
await home(undefined, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.chatsRoot).toBe('/srv/openchamber-chats');
|
||||
});
|
||||
|
||||
it('allows mkdir inside the relocated chats root outside the active workspace', async () => {
|
||||
const mkdirCalls = [];
|
||||
const { mkdir } = registerWithChatsRoot({
|
||||
managedChatsRoot: '/srv/openchamber-chats',
|
||||
fsPromises: {
|
||||
mkdir: async (targetPath) => {
|
||||
mkdirCalls.push(targetPath);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
await mkdir({ body: { path: '/srv/openchamber-chats/2026-08-25/session-a' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mkdirCalls).toEqual(['/srv/openchamber-chats/2026-08-25/session-a']);
|
||||
});
|
||||
|
||||
it('still rejects mkdir outside the workspace and all managed roots', async () => {
|
||||
const { mkdir } = registerWithChatsRoot({ managedChatsRoot: '/srv/openchamber-chats' });
|
||||
|
||||
const res = createMockResponse();
|
||||
await mkdir({ body: { path: '/etc/passwd-holder' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
createFsSearchRuntime,
|
||||
openchamberDataDir,
|
||||
openchamberUserConfigRoot,
|
||||
managedChatsRoot,
|
||||
normalizeDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
@@ -332,6 +333,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
buildAugmentedPath,
|
||||
resolveGitBinaryForSpawn,
|
||||
openchamberUserConfigRoot,
|
||||
managedChatsRoot,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user