From 3df97908fe8927473e413bcec350ae16cac6ce75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20M=C3=A4chtel?= Date: Sat, 5 Sep 2026 18:24:52 +0200 Subject: [PATCH] feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR (#3135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR Projectless-chat worktrees were hard-pinned to /.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: /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 /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 Co-authored-by: Bohdan Triapitsyn --- .../sidebar/list/sessionCollection.test.ts | 7 + .../sessions/sessionNodeItemUtils.test.ts | 7 + packages/ui/src/lib/chatDirectories.test.ts | 103 ++++++----- packages/ui/src/lib/chatDirectories.ts | 99 ++++++----- packages/ui/src/lib/opencode/client.test.ts | 55 +++++- packages/ui/src/lib/opencode/client.ts | 21 +++ packages/ui/src/stores/globalSessions.test.ts | 7 + .../useGlobalSessionsStore-chats-root.test.ts | 168 ++++++++++++++++++ .../useGlobalSessionsStore-races.test.ts | 6 + .../ui/src/stores/useGlobalSessionsStore.ts | 88 ++++++--- packages/ui/src/sync/DOCUMENTATION.md | 4 +- .../ui/src/sync/__tests__/issue-2039.test.ts | 1 + packages/ui/src/sync/bootstrap.ts | 3 + packages/ui/src/sync/persist-cache.test.ts | 8 +- packages/ui/src/sync/session-ui-store.test.js | 6 + packages/ui/src/types/bun-test.d.ts | 10 ++ packages/web/server/index.js | 8 +- packages/web/server/lib/fs/DOCUMENTATION.md | 2 + packages/web/server/lib/fs/routes.js | 53 +++--- packages/web/server/lib/fs/routes.test.js | 77 ++++++++ .../lib/opencode/feature-routes-runtime.js | 2 + 21 files changed, 602 insertions(+), 133 deletions(-) create mode 100644 packages/ui/src/stores/useGlobalSessionsStore-chats-root.test.ts diff --git a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts index 38de74e2..75f94024 100644 --- a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts +++ b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts @@ -1,3 +1,5 @@ +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; +import { opencodeClient } from '@/lib/opencode/client'; import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; import type { Event } from '@opencode-ai/sdk/v2/client'; @@ -331,3 +333,8 @@ describe('getDescendantIds', () => { expect(new Set(getDescendantIds(childrenMap, 'root')).size).toBe(3); }); }); + +const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; +opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home' }); +await ensureChatsRootDirectory(); +opencodeClient.getFilesystemHomeInfo = originalHomeInfo; diff --git a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts index a7ed4fae..cf35e3ed 100644 --- a/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts @@ -1,3 +1,5 @@ +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; +import { opencodeClient } from '@/lib/opencode/client'; import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -250,3 +252,8 @@ describe('canShowSessionWorktreeMenu', () => { })).toBe(true); }); }); + +const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; +opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/test' }); +await ensureChatsRootDirectory(); +opencodeClient.getFilesystemHomeInfo = originalHomeInfo; diff --git a/packages/ui/src/lib/chatDirectories.test.ts b/packages/ui/src/lib/chatDirectories.test.ts index 6d6958b4..6c2cc28b 100644 --- a/packages/ui/src/lib/chatDirectories.test.ts +++ b/packages/ui/src/lib/chatDirectories.test.ts @@ -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); }); }); diff --git a/packages/ui/src/lib/chatDirectories.ts b/packages/ui/src/lib/chatDirectories.ts index c7656dee..bce6f7b1 100644 --- a/packages/ui/src/lib/chatDirectories.ts +++ b/packages/ui/src/lib/chatDirectories.ts @@ -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>(); +type ChatRoots = { configured: string; legacy: string }; +const chatsRootByRuntime = new Map>(); +const chatsRootCacheByRuntime = new Map(); -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 { +async function getChatRoots(): Promise { 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 { 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 { + await getChatRoots(); +} + +export function warmChatsRootDirectory(): Promise { + return ensureChatsRootDirectory().catch(() => undefined); } export async function createChatDirectory(now = new Date()): Promise { - 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 { - 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 { - 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' }, diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 824b5827..c5455c3a 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -58,10 +58,19 @@ mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: mock(() => runtimeKey), })); +const fsHomeResponses: Array = []; + 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'); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 1a5f27e6..4e2a105a 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -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> { + 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 { if (!directoryPath || typeof directoryPath !== 'string' || !directoryPath.trim()) { console.warn('[OpencodeClient] setOpenCodeWorkingDirectory: invalid path', directoryPath); diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 10421d0c..9c1bf76f 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -1,3 +1,5 @@ +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; +import { opencodeClient } from '@/lib/opencode/client'; import { describe, expect, test } from 'bun:test' import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2' @@ -335,3 +337,8 @@ describe('splitGlobalSessionsByArchived', () => { expect(archived.map((session) => session.id)).toEqual(['ses_archived']) }) }) + +const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; +opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/user' }); +await ensureChatsRootDirectory(); +opencodeClient.getFilesystemHomeInfo = originalHomeInfo; diff --git a/packages/ui/src/stores/useGlobalSessionsStore-chats-root.test.ts b/packages/ui/src/stores/useGlobalSessionsStore-chats-root.test.ts new file mode 100644 index 00000000..c0dcbe67 --- /dev/null +++ b/packages/ui/src/stores/useGlobalSessionsStore-chats-root.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import { switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { persistSessions, readDirCache } from '@/sync/persist-cache'; +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; +import { useGlobalSessionsStore } from './useGlobalSessionsStore'; + +class TestStorage implements Storage { + readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +}; +const chat = (id: string): Session => ({ + id, slug: id, projectID: 'openchamber:chats', directory: '/srv/chats/day/session-' + id, + title: id, version: '1', time: { created: 1, updated: 2 }, +}); +const scope = 'openchamber:managed-chats'; +let runtime = 0; +const nextRuntime = () => switchRuntimeEndpoint({ apiBaseUrl: 'https://store-chats.test', runtimeKey: `store-chats-${++runtime}` }); +let home = spyOn(opencodeClient, 'getFilesystemHomeInfo'); +const originalStorage = globalThis.localStorage; + +beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: new TestStorage() }); + nextRuntime(); + useGlobalSessionsStore.getState().resetForRuntimeSwitch(); + home = spyOn(opencodeClient, 'getFilesystemHomeInfo').mockResolvedValue({ home: '/home/user', chatsRoot: '/srv/chats' }); +}); +afterEach(() => { + home.mockRestore(); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalStorage }); +}); +const seed = async () => { + persistSessions(scope, [chat('saved')]); + await new Promise((resolve) => setTimeout(resolve, 70)); + useGlobalSessionsStore.getState().resetForRuntimeSwitch(); +}; + +describe('global load owns chats-root readiness', () => { + test('concurrent loads wait for root, hydrate before request, and preserve saved chats after list failure', async () => { + await seed(); + const root = deferred<{ home: string; chatsRoot: string }>(); + home.mockImplementationOnce(() => root.promise); + const list = spyOn(opencodeClient.getSdkClient().experimental.session, 'list').mockImplementation(async () => { + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['saved']); + throw new Error('offline'); + }); + try { + const first = useGlobalSessionsStore.getState().loadSessions(); + const second = useGlobalSessionsStore.getState().loadSessions(); + expect(useGlobalSessionsStore.getState().status).toBe('idle'); + expect(list.mock.calls).toHaveLength(0); + root.resolve({ home: '/home/user', chatsRoot: '/srv/chats' }); + await Promise.all([first, second]); + expect(home.mock.calls).toHaveLength(1); + expect(useGlobalSessionsStore.getState().status).toBe('error'); + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['saved']); + expect(readDirCache(scope).sessions?.map((session) => session.id)).toEqual(['saved']); + } finally { list.mockRestore(); } + }); + + test('root failure keeps the persisted seed and retries without an authoritative empty write', async () => { + await seed(); + home.mockRejectedValueOnce(new Error('root offline')); + await useGlobalSessionsStore.getState().loadSessions(); + expect(readDirCache(scope).sessions?.map((session) => session.id)).toEqual(['saved']); + const list = spyOn(opencodeClient.getSdkClient().experimental.session, 'list').mockResolvedValue({ + data: [{ ...chat('saved'), project: null }], request: new Request('https://store-chats.test'), response: new Response('[]'), + }); + try { + await useGlobalSessionsStore.getState().loadSessions(); + expect(useGlobalSessionsStore.getState().status).toBe('ready'); + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['saved']); + } finally { list.mockRestore(); } + }); + + test('local create and delete before initial load preserve the saved seed and their mutations', async () => { + await seed(); + await ensureChatsRootDirectory(); + useGlobalSessionsStore.getState().upsertSession(chat('created')); + await new Promise((resolve) => setTimeout(resolve, 70)); + expect(readDirCache(scope).sessions?.map((session) => session.id).sort()).toEqual(['created', 'saved']); + useGlobalSessionsStore.getState().removeSessions(['saved']); + await new Promise((resolve) => setTimeout(resolve, 70)); + expect(readDirCache(scope).sessions?.map((session) => session.id)).toEqual(['created']); + const list = spyOn(opencodeClient.getSdkClient().experimental.session, 'list').mockRejectedValue(new Error('offline')); + try { + await useGlobalSessionsStore.getState().loadSessions(); + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['created']); + } finally { list.mockRestore(); } + }); + + test('a directory refresh before the global load retires the old seed even if the full load fails', async () => { + await seed(); + const refreshed = { ...chat('refreshed'), directory: chat('saved').directory }; + const list = spyOn(opencodeClient.getSdkClient().experimental.session, 'list').mockResolvedValue({ + data: [{ ...refreshed, project: null }], request: new Request('https://store-chats.test'), response: new Response('[]'), + }); + try { + await useGlobalSessionsStore.getState().refreshSessionsForDirectories([refreshed.directory]); + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['refreshed']); + list.mockRejectedValue(new Error('offline')); + await useGlobalSessionsStore.getState().loadSessions(); + expect(useGlobalSessionsStore.getState().activeSessions.map((session) => session.id)).toEqual(['refreshed']); + await new Promise((resolve) => setTimeout(resolve, 70)); + expect(readDirCache(scope).sessions?.map((session) => session.id)).toEqual(['refreshed']); + } finally { list.mockRestore(); } + }); + + test('hydration retains an archive mutation and its entity index when the initial list fails', async () => { + await seed(); + const archived = { ...chat('archived'), time: { created: 1, updated: 2, archived: 3 } }; + useGlobalSessionsStore.getState().upsertSession(archived); + const list = spyOn(opencodeClient.getSdkClient().experimental.session, 'list').mockRejectedValue(new Error('offline')); + try { + await useGlobalSessionsStore.getState().loadSessions(); + const state = useGlobalSessionsStore.getState(); + expect(state.activeSessions.map((session) => session.id)).toEqual(['saved']); + expect(state.archivedSessions).toEqual([archived]); + expect([...state.entityById.keys()].sort()).toEqual(['archived', 'saved']); + } finally { list.mockRestore(); } + }); + + test('runtime switch during root wait discards old work before global fetch or hydration', async () => { + await seed(); + const root = deferred<{ home: string; chatsRoot: string }>(); + home.mockImplementationOnce(() => root.promise); + const pending = useGlobalSessionsStore.getState().loadSessions(); + nextRuntime(); + useGlobalSessionsStore.getState().resetForRuntimeSwitch(); + root.resolve({ home: '/home/user', chatsRoot: '/srv/chats' }); + await pending; + expect(useGlobalSessionsStore.getState().status).toBe('idle'); + expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]); + expect(readDirCache(scope).sessions).toBe(undefined); + }); +}); diff --git a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts index a2a7aa90..c0d9f4ce 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts @@ -1,3 +1,4 @@ +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2" @@ -154,3 +155,8 @@ describe("global session mutation reconciliation", () => { expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([]) }) }) + +const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; +opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/user' }); +await ensureChatsRootDirectory(); +opencodeClient.getFilesystemHomeInfo = originalHomeInfo; diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 6cd00412..d451e6d1 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -9,6 +9,7 @@ import { raiseSessionOrderingBaselines } from '@/sync/session-ordering'; import { mapWithConcurrency } from '@/lib/concurrency'; import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { ensureChatsRootDirectory, getChatsRootForHome } from '@/lib/chatDirectories'; import { countSyncPerformance } from '@/sync/performance-diagnostics'; import { applyGlobalSessionStructureMutations, @@ -42,7 +43,11 @@ type GlobalSessionsState = { mutationRevision: number; mutationRevisionBySessionId: Map; hasLoaded: boolean; + managedChatsHydrated: boolean; status: GlobalSessionsStatus; + /** Re-read the persisted managed-chats snapshot after the chats root is + warm; retain newer mutations and stop after an authoritative load. */ + rehydrateManagedChatSessions: () => void; loadSessions: (fallbackActive?: Session[]) => Promise; refreshSessionsForDirectories: (directories: Iterable, fallbackActive?: Session[]) => Promise; applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void; @@ -586,20 +591,24 @@ const buildReviewTransferMap = (sessions: Session[]): Map ({ + activeSessions: sessions, + archivedSessions, + entityById: new Map([...sessions, ...archivedSessions].map((session) => [session.id, session])), + structure: buildGlobalSessionStructure(sessions), + sessionsByDirectory: buildSessionsByDirectory(sessions), + reviewTransferBySessionId: buildReviewTransferMap(sessions), +}); + const initialManagedChatSessions = readManagedChatSessions(); -const initialEntityById = new Map(initialManagedChatSessions.map((session) => [session.id, session])); -const initialStructure = buildGlobalSessionStructure(initialManagedChatSessions); +const initialState = buildManagedChatSessionsState(initialManagedChatSessions); export const useGlobalSessionsStore = create((set, get) => ({ - activeSessions: initialManagedChatSessions, - archivedSessions: [], - entityById: initialEntityById, - structure: initialStructure, - sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions), - reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions), + ...initialState, mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, + managedChatsHydrated: false, status: 'idle', applySnapshot: (activeSessions, archivedSessions, status = 'ready') => { @@ -615,21 +624,29 @@ export const useGlobalSessionsStore = create((set, get) => set((state) => applySessionMutations(state, mutations)); }, + // The module-init seed and runtime reset read the persisted snapshot before + // the server-resolved chats root is available, so relocated directories are + // filtered out of the stale sidebar paint until this runs after the warm-up. + rehydrateManagedChatSessions: () => { + const state = get(); + if (state.managedChatsHydrated || state.hasLoaded) return; + const hydrated = overlayMutationsSince(state, readManagedChatSessions(), state.archivedSessions, 0); + const unchanged = sameSessionList(hydrated.activeSessions, state.activeSessions) + && sameSessionList(hydrated.archivedSessions, state.archivedSessions); + set(unchanged + ? { managedChatsHydrated: true } + : { ...buildManagedChatSessionsState(hydrated.activeSessions, hydrated.archivedSessions), managedChatsHydrated: true }); + }, + resetForRuntimeSwitch: () => { loadGeneration += 1; inflightLoad = null; - const managedChatSessions = readManagedChatSessions(); - const entityById = new Map(managedChatSessions.map((session) => [session.id, session])); set({ - activeSessions: managedChatSessions, - archivedSessions: [], - entityById, - structure: buildGlobalSessionStructure(managedChatSessions), - sessionsByDirectory: buildSessionsByDirectory(managedChatSessions), - reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions), + ...buildManagedChatSessionsState(readManagedChatSessions()), mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, + managedChatsHydrated: false, status: 'idle', }); }, @@ -639,12 +656,16 @@ export const useGlobalSessionsStore = create((set, get) => return inflightLoad; } - set((state) => (state.status === 'loading' ? state : { status: 'loading' })); - const generation = loadGeneration; const baselineRevision = get().mutationRevision; const loadPromise = (async () => { + let rootsReady = false; try { + await ensureChatsRootDirectory(); + if (generation !== loadGeneration) return { activeSessions: [], archivedSessions: [] }; + rootsReady = true; + get().rehydrateManagedChatSessions(); + set((state) => (state.status === 'loading' ? state : { status: 'loading' })); const sdk = opencodeClient.getSdkClient(); // One inclusive fetch, split client-side. The server's // `time_archived IS NULL` active filter would exclude restored @@ -673,6 +694,13 @@ export const useGlobalSessionsStore = create((set, get) => if (generation !== loadGeneration) { return { activeSessions: [], archivedSessions: [] }; } + if (!rootsReady) { + // No classification authority arrived. Preserve both memory and the + // persisted snapshot so a retry can hydrate it after root recovery. + set({ status: 'error' }); + const state = get(); + return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; + } console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error); set((state) => { const reconciled = overlayMutationsSince( @@ -707,6 +735,17 @@ export const useGlobalSessionsStore = create((set, get) => const generation = loadGeneration; const baselineRevision = get().mutationRevision; + try { + await ensureChatsRootDirectory(); + } catch { + const state = get(); + return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; + } + if (generation !== loadGeneration) { + const state = get(); + return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions }; + } + get().rehydrateManagedChatSessions(); const sdk = opencodeClient.getSdkClient(); const fetched = await fetchDirectoryPages(sdk, directorySet); @@ -834,10 +873,17 @@ export const useGlobalSessionsStore = create((set, get) => useGlobalSessionsStore.subscribe((state, previous) => { countSyncPerformance('globalSessionPublications'); if ( - state.activeSessions !== previous.activeSessions - && (state.status !== 'idle' || state.activeSessions.length > 0) + getChatsRootForHome(null) !== null + && (state.activeSessions !== previous.activeSessions + || (!state.managedChatsHydrated && state.mutationRevision !== previous.mutationRevision) + || (state.hasLoaded && !previous.hasLoaded)) ) { - persistManagedChatSessions(state.activeSessions); + // A local mutation can precede the initial load. Preserve the saved seed + // and overlay its explicit mutations instead of persisting a partial list. + const sessions = !state.hasLoaded && !state.managedChatsHydrated + ? overlayMutationsSince(state, readManagedChatSessions(), [], 0).activeSessions + : state.activeSessions; + persistManagedChatSessions(sessions); } }); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index a70591cf..83bb0627 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -386,11 +386,11 @@ metadata and the next authoritative load reconciles it. ### Managed chat directories -Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories. +Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under the server-resolved managed chats root (`OPENCHAMBER_CHATS_DIR`, default `~/.config/openchamber/chats`) as `YYYY-MM-DD/session-` before creating the OpenCode session. That root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion accepts only descendants of the configured root or the actual server home's legacy chats root. It rejects both shared roots themselves, dot segments, lookalike paths elsewhere, and a runtime switch during root resolution. It never removes project directories. Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory. -The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list. +The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Full and directory-scoped global loads resolve the active server's chats roots before fetching or classifying sessions. The store hydrates its saved snapshot before leaving idle, preserving any newer mutations. Root lookup failure preserves the snapshot for a later retry; a failed global request retains the hydrated sessions. Old runtime completions cannot hydrate or fetch for the destination runtime. Persistence waits for root authority; before hydration it overlays explicit mutations onto the saved seed rather than replacing it with a partial list. Hydration happens once per runtime, so a later global load cannot undo an earlier directory refresh. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list. VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively. diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index b844049d..46ca3c33 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -82,6 +82,7 @@ mock.module("@/lib/opencode/client", () => ({ opencodeClient: { getDirectory: () => null, getFilesystemHome: mock(async () => "/home/test"), + getFilesystemHomeInfo: async () => ({ home: "/home/test" }), createDirectory: mock(async (path: string) => ({ success: true, path })), setDirectory: mock(() => undefined), }, diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 058a55e0..622432cf 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -3,6 +3,7 @@ import { retry } from "./retry" import type { GlobalState, State } from "./types" import { runtimeFetch } from "../lib/runtime-fetch" import { emitSyncConfigChanged } from "./sync-refs" +import { warmChatsRootDirectory } from "../lib/chatDirectories" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -68,6 +69,8 @@ export async function bootstrapGlobal( sdk: OpencodeClient, set: (patch: Partial) => void, ) { + // Sync chat classification needs the root before session lists load. + await warmChatsRootDirectory() const results = await Promise.allSettled([ retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))), retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))), diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts index 95427e33..e1c5afb9 100644 --- a/packages/ui/src/sync/persist-cache.test.ts +++ b/packages/ui/src/sync/persist-cache.test.ts @@ -1,3 +1,5 @@ +import { opencodeClient } from '@/lib/opencode/client'; +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { Session } from "@opencode-ai/sdk/v2/client" import { switchRuntimeEndpoint } from "@/lib/runtime-switch" @@ -69,10 +71,14 @@ const session = ( time: { created: updated - 1, updated }, } as Session) -beforeEach(() => { +beforeEach(async () => { storage = new TestStorage() Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage }) switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-default.test", runtimeKey: "runtime-default" }) + const originalHomeInfo = opencodeClient.getFilesystemHomeInfo + opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/user' }) + await ensureChatsRootDirectory() + opencodeClient.getFilesystemHomeInfo = originalHomeInfo }) afterEach(() => { diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 8946618b..496eee1d 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -1,3 +1,4 @@ +import { ensureChatsRootDirectory } from '@/lib/chatDirectories'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { opencodeClient } from '@/lib/opencode/client'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -1258,3 +1259,8 @@ describe('sendMessage effort record', () => { expect(readRecord()).toBe(undefined); }); }); + +const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; +opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/Users/tester' }); +await ensureChatsRootDirectory(); +opencodeClient.getFilesystemHomeInfo = originalHomeInfo; diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 977480cc..931daf52 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -41,6 +41,16 @@ declare module "bun:test" { mockReturnValue(value: ReturnType): Mock; mockReset(): Mock; } + export interface Spy void> extends Mock { + mock: { calls: Parameters[] }; + mockImplementation(fn: T): Spy; + mockImplementationOnce(fn: T): Spy; + mockResolvedValue(value: Awaited>): Spy; + mockRejectedValue(value: Error): Spy; + mockRejectedValueOnce(value: Error): Spy; + mockRestore(): void; + } + export function spyOn(target: T, method: K): Spy void>>; export function mock unknown>(fn?: T): Mock; export namespace mock { function module(moduleName: string, factory: () => Record): void; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6f7795a8..cf860665 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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, diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index 5701a37e..c3e7a797 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -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 `/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. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 5d247478..38aeb2ff 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -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 }); diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 4e2e9aa9..d592d532 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -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' }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index c0344a38..80bdd743 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -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, }); };