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
56 changes: 56 additions & 0 deletions docs/CODEX_DESKTOP_SESSION_SYNC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
## Codex Desktop Session Sync Notes

### Background

When a Codex session was created from the mobile/browser UI, the session could run normally, but it did not always appear in the Codex Desktop sidebar for the corresponding project.

### Symptoms

- A browser-created session was persisted under `~/.codex/sessions/...`.
- The same session was missing from the Codex Desktop sidebar, even though the session file existed.
- This was reproducible with Windows projects whose real path contained non-ASCII characters.

### Findings

1. The mobile UI uses `server/openai-codex.js` to start Codex SDK threads.
2. On Windows, non-ASCII project paths are redirected through an ASCII junction path such as `D:\codex_project_aliases\<sha1>`.
3. The persisted `session_meta.payload.cwd` could therefore be written as the ASCII alias instead of the real project path.
4. Even when `cwd` was already correct, mobile-created sessions were still written with `session_meta.payload.source = "exec"`.
5. Codex Desktop sessions that were visible in the sidebar were written with `session_meta.payload.source = "vscode"`.

### Root Cause

There were two compatibility mismatches between mobile-created sessions and Codex Desktop sidebar indexing:

- `session_meta.payload.cwd` could point to the temporary ASCII alias instead of the real workspace path.
- `session_meta.payload.source` was written as `exec`, while Codex Desktop-visible sessions used `vscode`.

### Fix

`server/openai-codex.js` now performs a post-start metadata sync for the persisted session file:

- Wait for the session file to appear under `~/.codex/sessions`.
- Rewrite `session_meta.payload.cwd` back to the real requested project path when the persisted path matches the ASCII alias.
- Rewrite `session_meta.payload.source` to `vscode` for Codex Desktop compatibility.

The runtime behavior of the Codex SDK is unchanged:

- The SDK can still execute from the ASCII alias path when needed.
- Only the persisted session metadata is normalized for Codex Desktop.

### Validation

Use the following checks:

1. Create a session from the mobile/browser UI.
2. Confirm that the new session file under `~/.codex/sessions/...` has:
- the real project path in `session_meta.payload.cwd`
- `session_meta.payload.source = "vscode"`
3. Refresh or restart Codex Desktop.
4. Confirm that the session appears in the sidebar under the correct project.

### Operational Notes

- Existing historical sessions may still need a one-time metadata migration if they were created before this fix.
- The runtime patch only affects newly created or resumed sessions after the updated server is deployed.
- The change is intentionally local to `server/openai-codex.js` and does not alter the project discovery logic elsewhere.
55 changes: 55 additions & 0 deletions docs/CODEX_DESKTOP_SESSION_SYNC.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## Codex Desktop 会话同步说明

### 背景

当会话是从手机端或浏览器端创建时,Codex 实际可以正常运行,但这条会话不一定会出现在 Codex Desktop 对应项目的左侧线程列表里。

### 现象

- 浏览器新建的会话确实已经写入 `~/.codex/sessions/...`
- 但 Codex Desktop 左侧列表里看不到它
- 在 Windows 非 ASCII 项目路径下,这个问题可以稳定复现

### 关键发现

1. 手机端会话是通过 `server/openai-codex.js` 调用 Codex SDK 创建的。
2. Windows 下如果项目路径包含非 ASCII 字符,会先切到一个 ASCII junction 路径,例如 `D:\codex_project_aliases\<sha1>`。
3. 因此落盘时 `session_meta.payload.cwd` 可能被写成这个临时别名路径,而不是真实项目路径。
4. 即使 `cwd` 本身就是对的,手机端新建的会话仍然会写成 `session_meta.payload.source = "exec"`。
5. 在 Codex Desktop 左侧能正常显示的会话,`session_meta.payload.source` 则是 `vscode`。

### 根因

手机端新建会话和 Codex Desktop 左侧索引之间,至少存在两个兼容性差异:

- `session_meta.payload.cwd` 可能落成 ASCII 别名路径,而不是实际工作目录
- `session_meta.payload.source` 会被写成 `exec`,而 Codex Desktop 可见线程使用的是 `vscode`

### 修复方式

现在 `server/openai-codex.js` 会在会话启动后,对落盘的会话文件做一次元数据回写:

- 等待 `~/.codex/sessions` 里对应会话文件出现
- 如果 `cwd` 落成了 ASCII 别名路径,就改回真实项目路径
- 把 `session_meta.payload.source` 统一改成 `vscode`,提升 Codex Desktop 侧边栏兼容性

这个修复不会改变 Codex SDK 的实际运行方式:

- 运行时仍然可以继续使用 ASCII 别名路径规避 Windows 非 ASCII 路径问题
- 只是在落盘元数据这一层做兼容修正

### 验证步骤

1. 从手机端或浏览器端新建一个会话
2. 检查 `~/.codex/sessions/...` 中对应文件的首条 `session_meta`
3. 确认:
- `cwd` 已经是真实项目路径
- `source = "vscode"`
4. 刷新或重启 Codex Desktop
5. 确认左侧线程列表已经能在正确项目下看到该会话

### 运行经验

- 这次修复只保证“更新后的服务创建的新会话”自动兼容
- 对于历史旧会话,必要时仍需要一次性元数据迁移
- 整个修复被控制在 `server/openai-codex.js` 内部,没有扩散到项目发现逻辑或数据库层
168 changes: 168 additions & 0 deletions upstream-overrides/claudecodeui-1.25.2/server/openai-codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,36 @@
import { Codex } from '@openai/codex-sdk';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';

// Track active sessions
const activeCodexSessions = new Map();
const CODEX_ONLY_HARDENED_MODE = process.env.CODEX_ONLY_HARDENED_MODE !== 'false';
const CODEX_SESSION_SYNC_TIMEOUT_MS = 5000;
const CODEX_SESSION_SYNC_RETRY_MS = 250;
// Codex Desktop groups sidebar threads using metadata from session_meta.
const CODEX_APP_SESSION_SOURCE = 'vscode';

const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/;

function containsNonAscii(value) {
return typeof value === 'string' && NON_ASCII_PATH_PATTERN.test(value);
}

function normalizeComparablePath(value) {
if (typeof value !== 'string' || !value.trim()) {
return '';
}

const normalized = path.normalize(value.trim());
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function ensureAsciiWorkingDirectory(projectPath) {
if (process.platform !== 'win32' || !containsNonAscii(projectPath)) {
return projectPath;
Expand Down Expand Up @@ -60,6 +78,141 @@ async function ensureAsciiWorkingDirectory(projectPath) {
return aliasPath;
}

async function findCodexSessionFile(sessionId, currentDir = path.join(os.homedir(), '.codex', 'sessions')) {
let entries;

try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return null;
}
throw error;
}

for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
const nestedMatch = await findCodexSessionFile(sessionId, fullPath);
if (nestedMatch) {
return nestedMatch;
}
continue;
}

if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) {
return fullPath;
}
}

return null;
}

async function rewriteCodexSessionMetadata(
sessionFilePath,
aliasWorkingDirectory,
requestedWorkingDirectory,
desiredSource = null
) {
const fileContent = await fs.readFile(sessionFilePath, 'utf8');
const newline = fileContent.includes('\r\n') ? '\r\n' : '\n';
const lines = fileContent.split(/\r?\n/);
const normalizedAliasPath = normalizeComparablePath(aliasWorkingDirectory);
const normalizedRequestedPath = normalizeComparablePath(requestedWorkingDirectory);
let foundSessionMeta = false;
let changed = false;

for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line.trim()) {
continue;
}

let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}

if (entry.type !== 'session_meta' || !entry.payload) {
continue;
}
foundSessionMeta = true;

const persistedPath = normalizeComparablePath(entry.payload.cwd);
if (
normalizedRequestedPath &&
persistedPath !== normalizedRequestedPath &&
persistedPath === normalizedAliasPath
) {
entry.payload.cwd = requestedWorkingDirectory;
changed = true;
}

if (desiredSource && entry.payload.source !== desiredSource) {
entry.payload.source = desiredSource;
changed = true;
}

if (!changed) {
return { found: true, updated: false };
}

lines[index] = JSON.stringify(entry);
await fs.writeFile(sessionFilePath, lines.join(newline), 'utf8');
return { found: true, updated: true };
}

return { found: foundSessionMeta, updated: false };
}

async function syncCodexSessionMetadata(
sessionId,
aliasWorkingDirectory,
requestedWorkingDirectory,
desiredSource = null
) {
if (!sessionId) {
return false;
}

const deadline = Date.now() + CODEX_SESSION_SYNC_TIMEOUT_MS;
while (Date.now() <= deadline) {
const sessionFilePath = await findCodexSessionFile(sessionId);
if (!sessionFilePath) {
await sleep(CODEX_SESSION_SYNC_RETRY_MS);
continue;
}

try {
const syncResult = await rewriteCodexSessionMetadata(
sessionFilePath,
aliasWorkingDirectory,
requestedWorkingDirectory,
desiredSource
);

if (syncResult.updated) {
console.log(
'[Codex] Synced persisted session metadata:',
requestedWorkingDirectory,
desiredSource || '(source unchanged)',
'for',
sessionId
);
}
return syncResult.found;
} catch (error) {
console.warn('[Codex] Failed to sync persisted session metadata for', sessionId, error);
return false;
}
}

console.warn('[Codex] Timed out waiting for persisted session metadata for', sessionId);
return false;
}

/**
* Transform Codex SDK event to WebSocket message format
* @param {object} event - SDK event
Expand Down Expand Up @@ -250,6 +403,7 @@ export async function queryCodex(command, options = {}, ws) {
let thread;
let currentSessionId = sessionId;
const abortController = new AbortController();
let sessionMetadataSyncPromise = null;

try {
// Initialize Codex SDK
Expand All @@ -273,6 +427,12 @@ export async function queryCodex(command, options = {}, ws) {

// Get the thread ID
currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;
sessionMetadataSyncPromise = syncCodexSessionMetadata(
currentSessionId,
workingDirectory,
requestedWorkingDirectory,
CODEX_APP_SESSION_SOURCE
);

// Track the session
activeCodexSessions.set(currentSessionId, {
Expand Down Expand Up @@ -352,6 +512,14 @@ export async function queryCodex(command, options = {}, ws) {
}

} finally {
if (sessionMetadataSyncPromise) {
try {
await sessionMetadataSyncPromise;
} catch (error) {
console.warn('[Codex] Session metadata sync promise rejected for', currentSessionId, error);
}
}

// Update session status
if (currentSessionId) {
const session = activeCodexSessions.get(currentSessionId);
Expand Down