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
94 changes: 93 additions & 1 deletion src/server/codexAppServerBridge.archive.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -75,3 +79,91 @@ 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: {
'/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',
])
})
})
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 @@ -77,7 +77,7 @@ type ServerRequestReply = {
}
}

type WorkspaceRootsState = {
export type WorkspaceRootsState = {
order: string[]
labels: Record<string, string>
active: string[]
Expand Down Expand Up @@ -1288,7 +1288,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 @@ -4142,6 +4145,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 @@ -4154,13 +4252,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']),
}
})
}

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 @@ -303,6 +303,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.

---

### Composio logged-out connector preview

#### Feature/Change Name
Expand Down