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
+59 -44
View File
@@ -1,55 +1,70 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { opencodeClient } from '@/lib/opencode/client';
import { switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { createChatDirectory, deleteChatDirectory, ensureChatsRootDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from './chatDirectories';
const createdDirectories: string[] = [];
const createDirectoryOptions: Array<{ allowOutsideWorkspace?: boolean } | undefined> = [];
const deletedDirectories: string[] = [];
let runtime = 0;
const nextRuntime = () => switchRuntimeEndpoint({ apiBaseUrl: 'https://chats.test', runtimeKey: `chats-${++runtime}` });
let home = spyOn(opencodeClient, 'getFilesystemHomeInfo');
let mkdir = spyOn(opencodeClient, 'createDirectory');
let request = spyOn(globalThis, 'fetch');
const deleteRequests = () => request.mock.calls.filter(([input]) => String(input).includes('/fs/delete'));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getFilesystemHome: mock(async () => '/Users/tester'),
createDirectory: mock(async (path: string, options?: { allowOutsideWorkspace?: boolean }) => {
createdDirectories.push(path);
createDirectoryOptions.push(options);
return { success: true, path };
}),
},
}));
beforeEach(() => {
nextRuntime();
home = spyOn(opencodeClient, 'getFilesystemHomeInfo').mockResolvedValue({ home: '/home/user', chatsRoot: '/srv/chats' });
mkdir = spyOn(opencodeClient, 'createDirectory').mockResolvedValue({ success: true, path: '/unused' });
request = spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
});
afterEach(() => { home.mockRestore(); mkdir.mockRestore(); request.mockRestore(); });
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async (_path: string, init?: RequestInit) => {
deletedDirectories.push(JSON.parse(String(init?.body)).path);
return new Response(null, { status: 200 });
}),
}));
const { createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } = await import('./chatDirectories');
describe('chat directories', () => {
beforeEach(() => {
createdDirectories.length = 0;
createDirectoryOptions.length = 0;
deletedDirectories.length = 0;
describe('server-owned chat directories', () => {
test('creates beneath the relocated root and uses the legacy root only for an older response', async () => {
expect((await createChatDirectory(new Date(2026, 8, 5))).startsWith('/srv/chats/2026-09-05/session-')).toBe(true);
nextRuntime();
home.mockResolvedValue({ home: '/home/user' });
expect((await createChatDirectory(new Date(2026, 8, 5))).startsWith('/home/user/.config/openchamber/chats/2026-09-05/session-')).toBe(true);
});
test('creates one isolated directory beneath the dated chats root', async () => {
const directory = await createChatDirectory(new Date(2026, 7, 21, 12));
expect(createdDirectories[0]).toBe(directory);
expect(directory.startsWith('/Users/tester/.config/openchamber/chats/2026-08-21/session-')).toBe(true);
expect(createdDirectories).toEqual([directory]);
expect(createDirectoryOptions).toEqual([undefined]);
test('classifies only exact configured and actual legacy roots after warming', async () => {
expect(isChatDirectoryPath('/work/backup/.config/openchamber/chats/project')).toBe(false);
await ensureChatsRootDirectory();
expect(isChatDirectoryPath('/srv/chats/day/session-a')).toBe(true);
expect(isChatDirectoryPath('/home/user/.config/openchamber/chats/day/session-a')).toBe(true);
expect(isChatDirectoryPath('/work/backup/.config/openchamber/chats/project')).toBe(false);
expect(isChatDirectoryPath('/srv/chats-other/session-a')).toBe(false);
expect(getChatsRootFromDirectory('/srv/chats/day/session-a')).toBe('/srv/chats');
expect(isChatDirectoryForHome('/other/.config/openchamber/chats/session-a', '/home/user')).toBe(false);
});
test('recognizes only descendants of the managed chats root', () => {
expect(isChatDirectoryForHome('/Users/tester/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
expect(isChatDirectoryForHome('/Users/tester/project', '/Users/tester')).toBe(false);
expect(isChatDirectoryForHome('/remote/home/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
expect(isChatDirectoryPath('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe(true);
expect(getChatsRootFromDirectory('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe('/remote/home/.config/openchamber/chats');
test('deletes real descendants but never shared roots, lookalikes, or traversal paths', async () => {
for (const path of ['/srv/chats', '/home/user/.config/openchamber/chats', '/work/backup/.config/openchamber/chats/project', '/srv/chats/../project']) {
await deleteChatDirectory(path);
}
expect(deleteRequests()).toHaveLength(0);
await deleteChatDirectory('/srv/chats/day/session-a');
await deleteChatDirectory('/home/user/.config/openchamber/chats/day/session-b');
expect(deleteRequests()).toHaveLength(2);
});
test('deletes managed chat directories but leaves project directories alone', async () => {
await deleteChatDirectory('/Users/tester/.config/openchamber/chats/2026-08-21/session-a');
await deleteChatDirectory('/Users/tester/project');
expect(deletedDirectories).toEqual(['/Users/tester/.config/openchamber/chats/2026-08-21/session-a']);
test('failed root lookup never creates or deletes, and the next attempt retries', async () => {
home.mockRejectedValueOnce(new Error('offline'));
await expect(deleteChatDirectory('/srv/chats/day/session-a')).rejects.toThrow('offline');
expect(deleteRequests()).toHaveLength(0);
home.mockRejectedValueOnce(new Error('offline'));
await warmChatsRootDirectory();
await createChatDirectory();
expect(mkdir.mock.calls).toHaveLength(1);
expect(home.mock.calls).toHaveLength(3);
});
test('runtime switch during root lookup cannot delete on the destination runtime', async () => {
let resolve!: (value: { home: string; chatsRoot: string }) => void;
home.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
const deletion = deleteChatDirectory('/srv/chats/day/session-a');
nextRuntime();
resolve({ home: '/home/user', chatsRoot: '/srv/chats' });
await expect(deletion).rejects.toThrow('Runtime changed');
expect(deleteRequests()).toHaveLength(0);
});
});
+59 -40
View File
@@ -4,48 +4,61 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey } from '@/lib/runtime-switch';
export const CHAT_DRAFT_PROJECT_ID = 'openchamber:chats';
const MANAGED_CHATS_PATH_SEGMENT = '/.config/openchamber/chats/';
const chatsRootByRuntime = new Map<string, Promise<string>>();
type ChatRoots = { configured: string; legacy: string };
const chatsRootByRuntime = new Map<string, Promise<ChatRoots>>();
const chatsRootCacheByRuntime = new Map<string, ChatRoots>();
const joinPath = (base: string, ...parts: string[]): string => {
const separator = base.includes('\\') ? '\\' : '/';
return [base.replace(/[\\/]+$/, ''), ...parts].join(separator);
};
const joinPath = (base: string, ...parts: string[]): string =>
[base.replace(/[\\/]+$/, ''), ...parts].join('/');
export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean {
const normalized = normalizePath(directory ?? null);
if (normalized?.includes(MANAGED_CHATS_PATH_SEGMENT)) return true;
const normalizedHome = normalizePath(home ?? null);
if (!normalized || !normalizedHome) return false;
const root = normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats'));
return Boolean(root && normalized.startsWith(`${root}/`));
function legacyRootForHome(home: string | null | undefined): string | null {
const normalized = normalizePath(home);
return normalized ? joinPath(normalized, '.config', 'openchamber', 'chats') : null;
}
export function isChatDirectoryPath(directory: string | null | undefined): boolean {
return normalizePath(directory ?? null)?.includes(MANAGED_CHATS_PATH_SEGMENT) === true;
function isWithinRoot(directory: string, root: string): boolean {
return !directory.split('/').some((part) => part === '..' || part === '.')
&& (directory === root || directory.startsWith(`${root}/`));
}
function cachedRoots(): ChatRoots | undefined {
return chatsRootCacheByRuntime.get(getRuntimeKey());
}
export function getChatsRootFromDirectory(directory: string | null | undefined): string | null {
const normalized = normalizePath(directory ?? null);
const index = normalized?.indexOf(MANAGED_CHATS_PATH_SEGMENT) ?? -1;
return normalized && index >= 0
? normalized.slice(0, index + MANAGED_CHATS_PATH_SEGMENT.length - 1)
: null;
const normalized = normalizePath(directory);
const roots = cachedRoots();
if (!normalized || !roots) return null;
if (isWithinRoot(normalized, roots.configured)) return roots.configured;
return isWithinRoot(normalized, roots.legacy) ? roots.legacy : null;
}
export function isChatDirectoryPath(directory: string | null | undefined): boolean {
return getChatsRootFromDirectory(directory) !== null;
}
export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean {
if (isChatDirectoryPath(directory)) return true;
const normalized = normalizePath(directory);
const legacy = legacyRootForHome(home);
return Boolean(normalized && legacy && isWithinRoot(normalized, legacy));
}
export function getChatsRootForHome(home: string | null | undefined): string | null {
const normalizedHome = normalizePath(home ?? null);
return normalizedHome ? normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')) : null;
return cachedRoots()?.configured ?? legacyRootForHome(home);
}
async function getChatsRootDirectory(): Promise<string> {
async function getChatRoots(): Promise<ChatRoots> {
const runtimeKey = getRuntimeKey();
const existing = chatsRootByRuntime.get(runtimeKey);
if (existing) return existing;
const pending = opencodeClient.getFilesystemHome().then((home) => {
if (!home) throw new Error('Unable to resolve the home directory');
return joinPath(home, '.config', 'openchamber', 'chats');
const pending = opencodeClient.getFilesystemHomeInfo().then(({ home, chatsRoot }) => {
const legacy = legacyRootForHome(home);
const configured = normalizePath(chatsRoot) ?? legacy;
if (!legacy || !configured) throw new Error('Unable to resolve chat directories');
const roots = { configured, legacy };
chatsRootCacheByRuntime.set(runtimeKey, roots);
return roots;
}).catch((error) => {
chatsRootByRuntime.delete(runtimeKey);
throw error;
@@ -54,29 +67,35 @@ async function getChatsRootDirectory(): Promise<string> {
return pending;
}
export function warmChatsRootDirectory(): void {
void getChatsRootDirectory().catch(() => undefined);
/** Required before a global snapshot can classify or persist managed chats. */
export async function ensureChatsRootDirectory(): Promise<void> {
await getChatRoots();
}
export function warmChatsRootDirectory(): Promise<void> {
return ensureChatsRootDirectory().catch(() => undefined);
}
export async function createChatDirectory(now = new Date()): Promise<string> {
const root = await getChatsRootDirectory();
const runtimeKey = getRuntimeKey();
const roots = await getChatRoots();
if (getRuntimeKey() !== runtimeKey) throw new Error('Runtime changed while preparing chat directory');
const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-');
const dateDirectory = joinPath(root, date);
const id = globalThis.crypto?.randomUUID?.() ?? `${now.getTime()}-${Math.random().toString(36).slice(2)}`;
const directory = joinPath(dateDirectory, `session-${id}`);
const directory = joinPath(roots.configured, date, `session-${id}`);
await opencodeClient.createDirectory(directory);
return directory;
}
async function isChatDirectory(directory: string | null | undefined): Promise<boolean> {
const normalized = normalizePath(directory ?? null);
if (!normalized) return false;
const root = normalizePath(await getChatsRootDirectory());
return Boolean(root && (normalized === root || normalized.startsWith(`${root}/`)));
}
export async function deleteChatDirectory(directory: string): Promise<void> {
if (!await isChatDirectory(directory)) return;
const normalized = normalizePath(directory);
if (!normalized) return;
const runtimeKey = getRuntimeKey();
const roots = await getChatRoots();
if (getRuntimeKey() !== runtimeKey) throw new Error('Runtime changed while deleting chat directory');
// A session may own a descendant, never either shared chats root itself.
if (normalized === roots.configured || normalized === roots.legacy) return;
if (!isWithinRoot(normalized, roots.configured) && !isWithinRoot(normalized, roots.legacy)) return;
const response = await runtimeFetch('/api/fs/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+52 -3
View File
@@ -58,10 +58,19 @@ mock.module('@/lib/runtime-switch', () => ({
getRuntimeKey: mock(() => runtimeKey),
}));
const fsHomeResponses: Array<Response | Error> = [];
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async () => new Response(JSON.stringify([]), {
headers: { 'Content-Type': 'application/json' },
})),
runtimeFetch: mock(async (input: string | URL | Request) => {
if (typeof input === 'string' && input.includes('/fs/home')) {
const next = fsHomeResponses.shift();
if (next instanceof Error) throw next;
if (next) return next;
}
return new Response(JSON.stringify([]), {
headers: { 'Content-Type': 'application/json' },
});
}),
}));
mock.module('@/lib/startupTrace', () => ({
@@ -75,6 +84,7 @@ beforeEach(() => {
promptAsyncCalls.length = 0;
promptAsyncResults.length = 0;
pathGetResults.length = 0;
fsHomeResponses.length = 0;
});
describe('opencodeClient directory availability', () => {
@@ -87,6 +97,45 @@ describe('opencodeClient directory availability', () => {
});
});
describe('opencodeClient getFilesystemHomeInfo', () => {
type HomePayload = { home?: string; chatsRoot?: string | number };
const fsHomeResponse = (body: HomePayload) => new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
});
test('returns the server-provided chats root', async () => {
fsHomeResponses.push(fsHomeResponse({ home: '/Users/tester', chatsRoot: '/srv/openchamber-chats' }));
expect(await opencodeClient.getFilesystemHomeInfo()).toEqual({ home: '/Users/tester', chatsRoot: '/srv/openchamber-chats' });
});
test('returns the home for an older server that answers without chatsRoot', async () => {
fsHomeResponses.push(fsHomeResponse({ home: '/Users/tester' }));
expect(await opencodeClient.getFilesystemHomeInfo()).toEqual({ home: '/Users/tester' });
});
test('throws on a failed fetch', async () => {
fsHomeResponses.push(new Error('transient network failure'));
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow('transient network failure');
});
test('throws on a non-ok response', async () => {
fsHomeResponses.push(new Response('unavailable', { status: 503 }));
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow('503');
});
test('rejects missing home and relative roots rather than caching a fallback', async () => {
fsHomeResponses.push(fsHomeResponse({}));
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
fsHomeResponses.push(fsHomeResponse({ home: '/home/user', chatsRoot: 'relative' }));
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
});
test('throws on a malformed payload', async () => {
fsHomeResponses.push(fsHomeResponse({ chatsRoot: 42 }));
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
});
});
describe('opencodeClient getConfig cache', () => {
test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => {
const first = opencodeClient.getConfig('/workspace/project');
+21
View File
@@ -3,6 +3,7 @@ import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
import type { FilesAPI } from "../api/types";
import { getDesktopHomeDirectory } from "../desktop";
import { z } from "zod";
import type {
Session,
Message,
@@ -341,6 +342,11 @@ const getDesktopFilesApi = (): FilesAPI | null => {
return null;
};
// /api/fs/home parsing boundary. Older servers answer without chatsRoot;
// Only a valid home response may use the legacy chats-root fallback.
const fsAbsolutePathSchema = z.string().trim().regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/);
const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() });
class OpencodeService {
private client: OpencodeClient;
private baseUrl: string;
@@ -1947,6 +1953,21 @@ class OpencodeService {
}
}
// Both roots must describe the same server response, including on desktop.
// Failure is distinct from an older server omitting chatsRoot.
async getFilesystemHomeInfo(): Promise<z.infer<typeof fsHomeResponseSchema>> {
const response = await runtimeFetch(`${this.baseUrl}/fs/home`, {
method: 'GET',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to resolve the chats root (${response.status})`);
}
return fsHomeResponseSchema.parse(await response.json());
}
async setOpenCodeWorkingDirectory(directoryPath: string | null | undefined): Promise<DirectorySwitchResult | null> {
if (!directoryPath || typeof directoryPath !== 'string' || !directoryPath.trim()) {
console.warn('[OpencodeClient] setOpenCodeWorkingDirectory: invalid path', directoryPath);