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:
Steffen Mächtel
2026-09-05 19:24:52 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1d6b15bc04
commit 3df97908fe
21 changed files with 602 additions and 133 deletions
+77
View File
@@ -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' });
});
});