diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index dc51a1ff..6ee28f94 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -1,10 +1,12 @@ -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { - callRpcWithArchiveRecovery, buildProjectlessFolderName, + callRpcWithArchiveRecovery, + canonicalizeThreadListResponseForRead, + canonicalizeWorkspaceRootsStateForRead, ensureDefaultFreeModeStateForMissingAuthSync, hasUsableCodexAuth, isEmptyThreadReadError, @@ -12,6 +14,7 @@ import { isThreadNotFoundError, isUnauthenticatedRateLimitError, writeFreeModeStateFile, + writeWorkspaceRootsState, } from './codexAppServerBridge' const originalCodexHome = process.env.CODEX_HOME @@ -152,6 +155,143 @@ describe('buildProjectlessFolderName', () => { }) }) +describe('canonicalizeWorkspaceRootsStateForRead', () => { + it('realpaths existing local roots so symlink cwd sessions remain visible', async () => { + const state = await canonicalizeWorkspaceRootsStateForRead({ + order: ['/workspace-link/projects/demo', 'remote-project-id'], + labels: { + '/storage/projects/demo': 'Canonical Demo', + '/workspace-link/projects/demo': 'Symlink Demo', + 'remote-project-id': 'Remote Demo', + }, + active: ['/workspace-link/projects/demo'], + projectOrder: ['remote-project-id', '/workspace-link/projects/demo'], + remoteProjects: [{ + id: 'remote-project-id', + hostId: 'remote-ssh-discovered:host', + remotePath: '/remote/projects/demo', + label: 'remote-demo', + }], + }, async (value) => value.replace('/workspace-link/', '/storage/')) + + expect(state.order).toEqual([ + '/storage/projects/demo', + 'remote-project-id', + ]) + expect(state.active).toEqual(['/storage/projects/demo']) + expect(state.projectOrder).toEqual([ + 'remote-project-id', + '/storage/projects/demo', + ]) + expect(state.labels).toEqual({ + '/storage/projects/demo': 'Canonical Demo', + 'remote-project-id': 'Remote Demo', + }) + expect(state.remoteProjects[0]?.id).toBe('remote-project-id') + }) +}) + +describe('writeWorkspaceRootsState', () => { + it('persists workspace roots in canonical form', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-workspace-roots-')) + const canonicalRoot = join(codexHome, 'storage', 'projects', 'demo') + const symlinkParent = join(codexHome, 'workspace-link', 'projects') + const symlinkRoot = join(symlinkParent, 'demo') + process.env.CODEX_HOME = codexHome + + try { + await mkdir(canonicalRoot, { recursive: true }) + await mkdir(symlinkParent, { recursive: true }) + await symlink(canonicalRoot, symlinkRoot) + await writeWorkspaceRootsState({ + order: [symlinkRoot, 'remote-project-id', canonicalRoot], + labels: { + [canonicalRoot]: 'Canonical Demo', + [symlinkRoot]: 'Symlink Demo', + 'remote-project-id': 'Remote Demo', + }, + active: [symlinkRoot, canonicalRoot], + projectOrder: ['remote-project-id', symlinkRoot, canonicalRoot], + remoteProjects: [{ + id: 'remote-project-id', + hostId: 'remote-ssh-discovered:host', + remotePath: '/remote/projects/demo', + label: 'remote-demo', + }], + }) + + const rawState = JSON.parse(await readFile(join(codexHome, '.codex-global-state.json'), 'utf8')) as Record + expect(rawState['electron-saved-workspace-roots']).toEqual([ + canonicalRoot, + 'remote-project-id', + ]) + expect(rawState['active-workspace-roots']).toEqual([canonicalRoot]) + expect(rawState['project-order']).toEqual([ + 'remote-project-id', + canonicalRoot, + ]) + expect(rawState['electron-workspace-root-labels']).toEqual({ + [canonicalRoot]: 'Canonical Demo', + 'remote-project-id': 'Remote Demo', + }) + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) +}) + +describe('canonicalizeThreadListResponseForRead', () => { + it('realpaths thread cwd values to match canonicalized workspace roots', async () => { + const payload = await canonicalizeThreadListResponseForRead({ + data: [ + { id: 'symlink-cwd-thread', cwd: '/workspace-link/projects/demo' }, + { id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'remote-thread', cwd: 'remote-project-id' }, + ], + nextCursor: null, + }, async (value) => value.replace('/workspace-link/', '/storage/')) + + expect(payload).toEqual({ + data: [ + { id: 'symlink-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'remote-thread', cwd: 'remote-project-id' }, + ], + nextCursor: null, + }) + }) + + it('reuses cwd realpath results within one thread list response', async () => { + const calls: string[] = [] + const payload = await canonicalizeThreadListResponseForRead({ + data: [ + { id: 'first-symlink-thread', cwd: '/workspace-link/projects/demo' }, + { id: 'second-symlink-thread', cwd: '/workspace-link/projects/demo' }, + { id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'remote-thread', cwd: 'remote-project-id' }, + ], + nextCursor: null, + }, async (value) => { + calls.push(value) + return value.replace('/workspace-link/', '/storage/') + }) + + expect(payload).toEqual({ + data: [ + { id: 'first-symlink-thread', cwd: '/storage/projects/demo' }, + { id: 'second-symlink-thread', cwd: '/storage/projects/demo' }, + { id: 'canonical-cwd-thread', cwd: '/storage/projects/demo' }, + { id: 'remote-thread', cwd: 'remote-project-id' }, + ], + nextCursor: null, + }) + expect(calls).toEqual([ + '/workspace-link/projects/demo', + '/storage/projects/demo', + ]) + }) +}) + describe('isUnauthenticatedRateLimitError', () => { it('matches unauthenticated rate-limit failures from a fresh Codex home', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to read rate limits'))).toBe(true) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 784a556c..b71c1be2 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -1,6 +1,6 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' -import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink, realpath } from 'node:fs/promises' import { createReadStream, existsSync, readFileSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { request as httpRequest } from 'node:http' @@ -83,7 +83,7 @@ type ServerRequestReply = { } } -type WorkspaceRootsState = { +export type WorkspaceRootsState = { order: string[] labels: Record active: string[] @@ -1498,7 +1498,10 @@ export async function callRpcWithArchiveRecovery( params: unknown, ): Promise { try { - return await callRpcWithRateLimitDecodeRecovery(appServer, method, params) + const result = await callRpcWithRateLimitDecodeRecovery(appServer, method, params) + return method === 'thread/list' + ? await canonicalizeThreadListResponseForRead(result) + : result } catch (error) { const paramsRecord = asRecord(params) const threadId = readNonEmptyString(paramsRecord?.threadId) @@ -4514,6 +4517,108 @@ async function readMergedThreadTitleCache(): Promise { return mergeThreadTitleCaches(persistedCache, sessionIndexCache) } +type PathRealpathResolver = (path: string) => Promise + +async function canonicalizeWorkspaceRootPath( + value: string, + pathRealpath: PathRealpathResolver, +): Promise { + if (!isAbsolute(value)) return value + try { + return await pathRealpath(value) + } catch { + return value + } +} + +async function canonicalizeWorkspaceRootPathList( + values: string[], + pathRealpath: PathRealpathResolver, +): Promise { + return normalizeStringArray(await Promise.all(values.map((value) => canonicalizeWorkspaceRootPath(value, pathRealpath)))) +} + +export async function canonicalizeWorkspaceRootsState( + state: WorkspaceRootsState, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + const [order, active, projectOrder] = await Promise.all([ + canonicalizeWorkspaceRootPathList(state.order, pathRealpath), + canonicalizeWorkspaceRootPathList(state.active, pathRealpath), + canonicalizeWorkspaceRootPathList(state.projectOrder, pathRealpath), + ]) + const labelEntries = await Promise.all( + Object.entries(state.labels) + .sort(([first], [second]) => first.localeCompare(second)) + .map(async ([key, label]) => { + const canonicalKey = await canonicalizeWorkspaceRootPath(key, pathRealpath) + return { + canonicalKey, + label, + isCanonicalSource: canonicalKey === key, + } + }), + ) + const labels: Record = {} + const labelSourceByCanonicalKey = new Map() + for (const entry of labelEntries) { + const existing = labelSourceByCanonicalKey.get(entry.canonicalKey) + if (existing?.isCanonicalSource === true && !entry.isCanonicalSource) continue + if (existing && existing.isCanonicalSource === entry.isCanonicalSource) continue + labels[entry.canonicalKey] = entry.label + labelSourceByCanonicalKey.set(entry.canonicalKey, { + isCanonicalSource: entry.isCanonicalSource, + }) + } + + return { + order, + labels, + active, + projectOrder, + remoteProjects: state.remoteProjects.map((project) => ({ ...project })), + } +} + +export async function canonicalizeWorkspaceRootsStateForRead( + state: WorkspaceRootsState, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + return await canonicalizeWorkspaceRootsState(state, pathRealpath) +} + +async function canonicalizeThreadCwdRecord( + value: unknown, + canonicalizeCwd: (cwd: string) => Promise, +): Promise { + const record = asRecord(value) + const cwd = typeof record?.cwd === 'string' ? record.cwd : '' + if (!record || !cwd) return value + const canonicalCwd = await canonicalizeCwd(cwd) + return canonicalCwd === cwd ? value : { ...record, cwd: canonicalCwd } +} + +export async function canonicalizeThreadListResponseForRead( + payload: unknown, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + const record = asRecord(payload) + if (!record || !Array.isArray(record.data)) return payload + const cwdCanonicalizationByValue = new Map>() + const canonicalizeCwd = (cwd: string): Promise => { + let canonicalized = cwdCanonicalizationByValue.get(cwd) + if (!canonicalized) { + canonicalized = canonicalizeWorkspaceRootPath(cwd, pathRealpath) + cwdCanonicalizationByValue.set(cwd, canonicalized) + } + return canonicalized + } + return { + ...record, + data: await Promise.all(record.data.map((item) => canonicalizeThreadCwdRecord(item, canonicalizeCwd))), + } +} + async function readWorkspaceRootsState(): Promise { const statePath = getCodexGlobalStatePath() let payload: Record = {} @@ -4526,16 +4631,17 @@ async function readWorkspaceRootsState(): Promise { payload = {} } - return { + return await canonicalizeWorkspaceRootsState({ order: normalizeStringArray(payload['electron-saved-workspace-roots']), labels: normalizeStringRecord(payload['electron-workspace-root-labels']), active: normalizeStringArray(payload['active-workspace-roots']), projectOrder: normalizeStringArray(payload['project-order']), remoteProjects: normalizeRemoteProjects(payload['remote-projects']), - } + }) } -async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise { +export async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise { + const state = await canonicalizeWorkspaceRootsState(nextState) const statePath = getCodexGlobalStatePath() let payload: Record = {} try { @@ -4545,10 +4651,10 @@ async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise payload = {} } - payload['electron-saved-workspace-roots'] = normalizeStringArray(nextState.order) - payload['electron-workspace-root-labels'] = normalizeStringRecord(nextState.labels) - payload['active-workspace-roots'] = normalizeStringArray(nextState.active) - payload['project-order'] = normalizeStringArray(nextState.projectOrder) + payload['electron-saved-workspace-roots'] = normalizeStringArray(state.order) + payload['electron-workspace-root-labels'] = normalizeStringRecord(state.labels) + payload['active-workspace-roots'] = normalizeStringArray(state.active) + payload['project-order'] = normalizeStringArray(state.projectOrder) await writeFile(statePath, JSON.stringify(payload), 'utf8') } diff --git a/tests.md b/tests.md index 1bd67f03..358036f1 100644 --- a/tests.md +++ b/tests.md @@ -336,6 +336,42 @@ Rollback/cleanup: --- +### Sidebar sessions survive symlinked workspace roots + +#### Feature/Change Name +Workspace roots and thread-list cwd values are canonicalized through local `realpath` before the sidebar filters thread projects and before workspace-root state is written, so sessions remain visible whether they were recorded through a symlink path or its target. + +#### Prerequisites/Setup +1. Dev server running (`pnpm run dev`) +2. A workspace root registered through a symlink path, for example `/workspace-link/projects/demo` +3. At least one session recorded with the canonical cwd, for example `/storage/projects/demo` +4. Light theme and dark theme both available from the appearance switcher + +#### Steps +1. In light theme, open the app and wait for the sidebar thread list to load. +2. Confirm a session recorded under the canonical cwd appears in the sidebar. +3. Confirm a session recorded under the symlink cwd also appears in the sidebar. +4. Search for both known session titles and confirm both rows remain findable. +5. Fetch `/codex-api/workspace-roots-state` and confirm local symlink roots are returned as their canonical real paths. +6. If both symlink and canonical forms have saved labels, confirm only the canonical path label is returned and displayed. +7. Add or update a workspace root through the UI using the symlink path, then reload `/codex-api/workspace-roots-state` and confirm the saved root remains in canonical form. +8. Fetch `thread/list` with multiple sessions that share the same cwd and confirm the rows still show under the canonical project. +9. Switch to dark theme and repeat steps 1-4. + +#### Expected Results +- A registered symlink root and a session cwd pointing at the symlink target are treated as the same project. +- Sessions recorded through either path form are not filtered out as unregistered workspace roots. +- Duplicate symlink/canonical labels collapse deterministically to the canonical path label. +- Workspace-root writes do not reintroduce symlink/canonical duplicates into persisted state. +- Repeated cwd values in one `thread/list` response reuse the same canonical path result and do not change visible rows. +- Search and sidebar browsing both expose the session. +- Rows remain readable in light and dark themes. + +#### Rollback/Cleanup +- None. + +--- + ### Automation editor scrolls on small viewports #### Feature/Change Name