From a5a47f0a85775d078cf92d8355a639dc813355b7 Mon Sep 17 00:00:00 2001 From: YuMS Date: Mon, 11 May 2026 13:57:27 +0000 Subject: [PATCH 1/4] Canonicalize workspace roots for session visibility --- .../codexAppServerBridge.archive.test.ts | 59 +++++++++++++- src/server/codexAppServerBridge.ts | 81 +++++++++++++++++-- tests.md | 30 +++++++ 3 files changed, 164 insertions(+), 6 deletions(-) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 27def41e4..df427a047 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { callRpcWithArchiveRecovery } from './codexAppServerBridge' +import { + callRpcWithArchiveRecovery, + canonicalizeThreadListResponseForRead, + canonicalizeWorkspaceRootsStateForRead, +} from './codexAppServerBridge' describe('callRpcWithArchiveRecovery', () => { it('sets a fallback name and retries archive when Codex has not materialized a rollout', async () => { @@ -75,3 +79,56 @@ describe('callRpcWithArchiveRecovery', () => { await expect(callRpcWithArchiveRecovery(appServer, 'thread/read', { threadId: 'test-thread' })).rejects.toThrow('network failed') }) }) + +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: { + '/workspace-link/projects/demo': '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['/storage/projects/demo']).toBe('Demo') + expect(state.remoteProjects[0]?.id).toBe('remote-project-id') + }) +}) + +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, + }) + }) +}) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 93c0bf849..fd9d8bafe 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, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { request as httpRequest } from 'node:http' @@ -76,7 +76,7 @@ type ServerRequestReply = { } } -type WorkspaceRootsState = { +export type WorkspaceRootsState = { order: string[] labels: Record active: string[] @@ -1228,7 +1228,10 @@ export async function callRpcWithArchiveRecovery( params: unknown, ): Promise { try { - return await appServer.rpc(method, params ?? null) + const result = await appServer.rpc(method, params ?? null) + return method === 'thread/list' + ? await canonicalizeThreadListResponseForRead(result) + : result } catch (error) { if (method !== 'thread/archive') { throw error @@ -3869,6 +3872,74 @@ 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 canonicalizeWorkspaceRootsStateForRead( + 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 labels: Record = { ...state.labels } + await Promise.all(Object.entries(state.labels).map(async ([key, label]) => { + const canonicalKey = await canonicalizeWorkspaceRootPath(key, pathRealpath) + labels[canonicalKey] = label + })) + + return { + order, + labels, + active, + projectOrder, + remoteProjects: state.remoteProjects.map((project) => ({ ...project })), + } +} + +async function canonicalizeThreadCwdRecord( + value: unknown, + pathRealpath: PathRealpathResolver = realpath, +): Promise { + const record = asRecord(value) + const cwd = typeof record?.cwd === 'string' ? record.cwd : '' + if (!record || !cwd) return value + const canonicalCwd = await canonicalizeWorkspaceRootPath(cwd, pathRealpath) + 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 + return { + ...record, + data: await Promise.all(record.data.map((item) => canonicalizeThreadCwdRecord(item, pathRealpath))), + } +} + async function readWorkspaceRootsState(): Promise { const statePath = getCodexGlobalStatePath() let payload: Record = {} @@ -3881,13 +3952,13 @@ async function readWorkspaceRootsState(): Promise { payload = {} } - return { + return await canonicalizeWorkspaceRootsStateForRead({ 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 { diff --git a/tests.md b/tests.md index a185cc9eb..0b89780cb 100644 --- a/tests.md +++ b/tests.md @@ -271,6 +271,36 @@ This file tracks manual regression and feature verification steps. --- +### 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, 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. 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. +- Search and sidebar browsing both expose the session. +- Rows remain readable in light and dark themes. + +#### Rollback/Cleanup +- None. + +--- + ### Pinned threads remain visible during background pagination #### Feature/Change Name From 0d6ea2dba519b11d75623aca1b27d3b5509ace77 Mon Sep 17 00:00:00 2001 From: YuMS Date: Tue, 12 May 2026 02:46:05 +0000 Subject: [PATCH 2/4] Make workspace root label canonicalization deterministic --- .../codexAppServerBridge.archive.test.ts | 9 ++++-- src/server/codexAppServerBridge.ts | 28 +++++++++++++++---- tests.md | 4 ++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index df427a047..e5f7d2f25 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -85,7 +85,9 @@ describe('canonicalizeWorkspaceRootsStateForRead', () => { const state = await canonicalizeWorkspaceRootsStateForRead({ order: ['/workspace-link/projects/demo', 'remote-project-id'], labels: { - '/workspace-link/projects/demo': 'Demo', + '/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'], @@ -106,7 +108,10 @@ describe('canonicalizeWorkspaceRootsStateForRead', () => { 'remote-project-id', '/storage/projects/demo', ]) - expect(state.labels['/storage/projects/demo']).toBe('Demo') + expect(state.labels).toEqual({ + '/storage/projects/demo': 'Canonical Demo', + 'remote-project-id': 'Remote Demo', + }) expect(state.remoteProjects[0]?.id).toBe('remote-project-id') }) }) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index fd9d8bafe..14a80bc42 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -3902,11 +3902,29 @@ export async function canonicalizeWorkspaceRootsStateForRead( canonicalizeWorkspaceRootPathList(state.active, pathRealpath), canonicalizeWorkspaceRootPathList(state.projectOrder, pathRealpath), ]) - const labels: Record = { ...state.labels } - await Promise.all(Object.entries(state.labels).map(async ([key, label]) => { - const canonicalKey = await canonicalizeWorkspaceRootPath(key, pathRealpath) - labels[canonicalKey] = label - })) + 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, diff --git a/tests.md b/tests.md index 0b89780cb..ae6c59499 100644 --- a/tests.md +++ b/tests.md @@ -288,11 +288,13 @@ Workspace roots and thread-list cwd values are canonicalized through local `real 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. Switch to dark theme and repeat steps 1-4. +6. If both symlink and canonical forms have saved labels, confirm only the canonical path label is returned and displayed. +7. 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. - Search and sidebar browsing both expose the session. - Rows remain readable in light and dark themes. From 4a603ea6ee3241a171310df971061ba90ddaea1c Mon Sep 17 00:00:00 2001 From: YuMS Date: Tue, 12 May 2026 03:15:37 +0000 Subject: [PATCH 3/4] Cache thread cwd canonicalization --- .../codexAppServerBridge.archive.test.ts | 30 +++++++++++++++++++ src/server/codexAppServerBridge.ts | 15 ++++++++-- tests.md | 4 ++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index e5f7d2f25..e8826bbad 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -136,4 +136,34 @@ describe('canonicalizeThreadListResponseForRead', () => { 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', + ]) + }) }) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 1b35e4fca..e426d81d7 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -4210,12 +4210,12 @@ export async function canonicalizeWorkspaceRootsStateForRead( async function canonicalizeThreadCwdRecord( value: unknown, - pathRealpath: PathRealpathResolver = realpath, + 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 canonicalizeWorkspaceRootPath(cwd, pathRealpath) + const canonicalCwd = await canonicalizeCwd(cwd) return canonicalCwd === cwd ? value : { ...record, cwd: canonicalCwd } } @@ -4225,9 +4225,18 @@ export async function canonicalizeThreadListResponseForRead( ): 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, pathRealpath))), + data: await Promise.all(record.data.map((item) => canonicalizeThreadCwdRecord(item, canonicalizeCwd))), } } diff --git a/tests.md b/tests.md index 9a0711cf8..665498b74 100644 --- a/tests.md +++ b/tests.md @@ -321,12 +321,14 @@ Workspace roots and thread-list cwd values are canonicalized through local `real 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. Switch to dark theme and repeat steps 1-4. +7. Fetch `thread/list` with multiple sessions that share the same cwd and confirm the rows still show under the canonical project. +8. 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. +- 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. From 4895d269ab561940eef4cb8410f0c8fb4f685082 Mon Sep 17 00:00:00 2001 From: Zader Date: Wed, 13 May 2026 16:32:49 +0800 Subject: [PATCH 4/4] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/server/codexAppServerBridge.archive.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 2b9dc2609..84b2c45a9 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -181,6 +181,8 @@ describe('canonicalizeThreadListResponseForRead', () => { '/workspace-link/projects/demo', '/storage/projects/demo', ]) + }) +}) describe('isUnauthenticatedRateLimitError', () => { it('matches unauthenticated rate-limit failures from a fresh Codex home', () => {