Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/server/codexAppServerBridge.archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
callRpcWithArchiveRecovery,
canonicalizeThreadListResponseForRead,
canonicalizeWorkspaceRootsStateForRead,
hasUsableCodexAuth,
isEmptyThreadReadError,
isUnauthenticatedRateLimitError,
Expand Down Expand Up @@ -94,6 +96,94 @@ describe('callRpcWithArchiveRecovery', () => {
})
})

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('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',
])
Comment thread
YuMS marked this conversation as resolved.
})
})

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)
Expand Down
108 changes: 103 additions & 5 deletions src/server/codexAppServerBridge.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -79,7 +79,7 @@ type ServerRequestReply = {
}
}

type WorkspaceRootsState = {
export type WorkspaceRootsState = {
order: string[]
labels: Record<string, string>
active: string[]
Expand Down Expand Up @@ -1356,7 +1356,10 @@ export async function callRpcWithArchiveRecovery(
params: unknown,
): Promise<unknown> {
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
Expand Down Expand Up @@ -4272,6 +4275,101 @@ async function readMergedThreadTitleCache(): Promise<ThreadTitleCache> {
return mergeThreadTitleCaches(persistedCache, sessionIndexCache)
}

type PathRealpathResolver = (path: string) => Promise<string>

async function canonicalizeWorkspaceRootPath(
value: string,
pathRealpath: PathRealpathResolver,
): Promise<string> {
if (!isAbsolute(value)) return value
try {
return await pathRealpath(value)
} catch {
return value
}
}

async function canonicalizeWorkspaceRootPathList(
values: string[],
pathRealpath: PathRealpathResolver,
): Promise<string[]> {
return normalizeStringArray(await Promise.all(values.map((value) => canonicalizeWorkspaceRootPath(value, pathRealpath))))
}

export async function canonicalizeWorkspaceRootsStateForRead(
state: WorkspaceRootsState,
pathRealpath: PathRealpathResolver = realpath,
): Promise<WorkspaceRootsState> {
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<string, string> = {}
const labelSourceByCanonicalKey = new Map<string, { isCanonicalSource: boolean }>()
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 })),
}
}

async function canonicalizeThreadCwdRecord(
value: unknown,
canonicalizeCwd: (cwd: string) => Promise<string>,
): Promise<unknown> {
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<unknown> {
const record = asRecord(payload)
if (!record || !Array.isArray(record.data)) return payload
const cwdCanonicalizationByValue = new Map<string, Promise<string>>()
const canonicalizeCwd = (cwd: string): Promise<string> => {
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<WorkspaceRootsState> {
const statePath = getCodexGlobalStatePath()
let payload: Record<string, unknown> = {}
Expand All @@ -4284,13 +4382,13 @@ async function readWorkspaceRootsState(): Promise<WorkspaceRootsState> {
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']),
}
})
Comment on lines +4385 to +4391
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Canonicalize before persisting, not only when reading.

This normalizes the API response, but new roots written through persistWorkspaceRoot() and PUT /codex-api/workspace-roots-state still hit disk as raw symlink paths because writeWorkspaceRootsState() stores nextState unchanged. That leaves the state file with mixed canonical/non-canonical entries and can reintroduce duplicates for any code path that reads the file directly.

Suggested direction
async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise<void> {
+  const canonicalState = await canonicalizeWorkspaceRootsStateForRead(nextState)
   const statePath = getCodexGlobalStatePath()
   let payload: Record<string, unknown> = {}
   try {
     const raw = await readFile(statePath, 'utf8')
     payload = asRecord(JSON.parse(raw)) ?? {}
   } catch {
     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(canonicalState.order)
+  payload['electron-workspace-root-labels'] = normalizeStringRecord(canonicalState.labels)
+  payload['active-workspace-roots'] = normalizeStringArray(canonicalState.active)
+  payload['project-order'] = normalizeStringArray(canonicalState.projectOrder)

   await writeFile(statePath, JSON.stringify(payload), 'utf8')
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/codexAppServerBridge.ts` around lines 4385 - 4391, The state is
being canonicalized only on read but not before persisting, so
writeWorkspaceRootsState() receives raw symlink paths (nextState) and the file
can contain mixed entries; update persistWorkspaceRoot() and the handler that
serves PUT /codex-api/workspace-roots-state to canonicalize the outgoing state
before calling writeWorkspaceRootsState()—e.g., call
canonicalizeWorkspaceRootsStateForRead(nextState) (or extract a small
canonicalizeForWrite helper) and pass its result into writeWorkspaceRootsState()
instead of nextState so the on-disk file stores canonical paths and avoids
reintroducing duplicates.

}

async function writeWorkspaceRootsState(nextState: WorkspaceRootsState): Promise<void> {
Expand Down
34 changes: 34 additions & 0 deletions tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,40 @@ 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, 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. 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.

#### Rollback/Cleanup
- None.

---

### Qodo feedback diagnostics reliability fixes

#### Feature/Change Name
Expand Down