diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 90a4335dc3..853b797bdd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2004,6 +2004,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", + "codex-utils-path", "codex-utils-pty", "codex-web-search-extension", "codex-windows-sandbox", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1c37ea6f33..883e62990a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3385,7 +3385,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -4558,7 +4558,12 @@ "$ref": "#/definitions/CollaborationMode" }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "effort": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 82646d0ec6..adf533703e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15893,7 +15893,7 @@ "$ref": "#/definitions/v2/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -16249,7 +16249,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], @@ -17927,7 +17932,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], @@ -18080,7 +18090,12 @@ "$ref": "#/definitions/v2/CollaborationMode" }, "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "effort": { "anyOf": [ @@ -18343,7 +18358,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6be7e76eb7..da4aecb220 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13710,7 +13710,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -14066,7 +14066,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], @@ -15744,7 +15749,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], @@ -15897,7 +15907,12 @@ "$ref": "#/definitions/CollaborationMode" }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "effort": { "anyOf": [ @@ -16160,7 +16175,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index fa1277b0ef..84b93f2524 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -999,7 +999,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -2293,7 +2293,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 1b53d4ca2e..fdcf758f88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index c98fa1f2a5..6072646b65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 345d84d794..5ca495160d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 98ef79517b..25eb3e957e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -999,7 +999,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -2319,7 +2319,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f63dfe6ffb..a4539c0f2d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index fbcaee3ee8..5a397026a9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -297,7 +297,12 @@ "$ref": "#/definitions/CollaborationMode" }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "effort": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 052023361a..01b045c5d8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -999,7 +999,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", @@ -2293,7 +2293,12 @@ "description": "Reviewer currently used for approval requests on this thread." }, "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Current working directory. Existing local paths are returned in canonical form." }, "instructionSources": { "default": [], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index cdeda64b7c..2601e75846 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 2a7281fcbb..2642208c64 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -814,7 +814,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Working directory captured for the thread." + "description": "Working directory captured for the thread. Existing local paths are best-effort canonicalized with symbolic links resolved; unresolved paths are unchanged." }, "ephemeral": { "description": "Whether the thread is ephemeral and should not be materialized on disk.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5fa30b64e3..88b969afc0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -50,7 +50,8 @@ status: ThreadStatus, */ path: string | null, /** - * Working directory captured for the thread. + * Working directory captured for the thread. Existing local paths are best-effort + * canonicalized with symbolic links resolved; unresolved paths are unchanged. */ cwd: AbsolutePathBuf, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c5b1201c26..f3d9771e69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -8,7 +8,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, /** + * Current working directory. Existing local paths are returned in canonical form. + */ +cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 7a4f90377c..eca7f45fa9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -8,7 +8,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, /** + * Current working directory. Existing local paths are returned in canonical form. + */ +cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts index bcfd0ad86c..9831fc8b7b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -11,4 +11,8 @@ import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; +export type ThreadSettings = { +/** + * Current working directory. Existing local paths are returned in canonical form. + */ +cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 38859a3805..0830147822 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -8,7 +8,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, /** + * Current working directory. Existing local paths are returned in canonical form. + */ +cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 5b412c2edd..80ab8b4d26 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -196,6 +196,7 @@ pub struct ThreadStartResponse { pub model: String, pub model_provider: String, pub service_tier: Option, + /// Current working directory. Existing local paths are returned in canonical form. pub cwd: AbsolutePathBuf, /// Thread-scoped runtime workspace roots used to materialize /// `:workspace_roots`. @@ -285,6 +286,7 @@ pub struct ThreadSettingsUpdateResponse {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadSettings { + /// Current working directory. Existing local paths are returned in canonical form. pub cwd: AbsolutePathBuf, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, @@ -410,6 +412,7 @@ pub struct ThreadResumeResponse { pub model: String, pub model_provider: String, pub service_tier: Option, + /// Current working directory. Existing local paths are returned in canonical form. pub cwd: AbsolutePathBuf, /// Thread-scoped runtime workspace roots used to materialize /// `:workspace_roots`. @@ -558,6 +561,7 @@ pub struct ThreadForkResponse { pub model: String, pub model_provider: String, pub service_tier: Option, + /// Current working directory. Existing local paths are returned in canonical form. pub cwd: AbsolutePathBuf, /// Thread-scoped runtime workspace roots used to materialize /// `:workspace_roots`. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index fc0762361f..378b3abd60 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -156,7 +156,8 @@ pub struct Thread { pub status: ThreadStatus, /// [UNSTABLE] Path to the thread on disk. pub path: Option, - /// Working directory captured for the thread. + /// Working directory captured for the thread. Existing local paths are best-effort + /// canonicalized with symbolic links resolved; unresolved paths are unchanged. pub cwd: AbsolutePathBuf, /// Version of the CLI that created the thread. pub cli_version: String, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 55ea78e617..dd4e322d53 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -51,6 +51,7 @@ codex-plugin = { workspace = true } codex-shell-command = { workspace = true } codex-skills-extension = { workspace = true } codex-utils-cli = { workspace = true } +codex-utils-path = { workspace = true } codex-utils-pty = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 42ac510a8a..323b195cd1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -134,6 +134,7 @@ Example with notification opt-out: - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- Local thread paths returned as `cwd`, including nested `thread.cwd` values from `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/read`, and `thread/started`, are best-effort canonicalized so filesystem aliases resolve to one stable spelling. Paths that the app-server host cannot resolve retain their original spelling. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f47bc4dd8..af7a1110d9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1235,8 +1235,11 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ThreadSettingsApplied(thread_settings_event) => { - let thread_settings = - thread_settings_from_core_snapshot(thread_settings_event.thread_settings); + let config_snapshot = conversation.config_snapshot().await; + let thread_settings = thread_settings_from_core_snapshot( + thread_settings_event.thread_settings, + &config_snapshot, + ); let changed = { let mut state = thread_state.lock().await; state.note_thread_settings(thread_settings.clone()) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 83e704cdfd..01d40d09f1 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -531,6 +531,28 @@ fn resolve_request_cwd(cwd: Option) -> Result, .transpose() } +fn thread_response_cwd_for_path( + cwd: AbsolutePathBuf, + config_snapshot: &ThreadConfigSnapshot, +) -> AbsolutePathBuf { + let primary_environment_is_local = config_snapshot + .environment_selections() + .first() + .is_none_or(|environment| environment.environment_id == LOCAL_ENVIRONMENT_ID); + if !primary_environment_is_local { + return cwd; + } + + let Ok(canonical) = codex_utils_path::normalize_for_path_comparison(cwd.as_path()) else { + return cwd; + }; + AbsolutePathBuf::from_absolute_path(canonical).unwrap_or(cwd) +} + +fn thread_response_cwd(config_snapshot: &ThreadConfigSnapshot) -> AbsolutePathBuf { + thread_response_cwd_for_path(config_snapshot.cwd().clone(), config_snapshot) +} + fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { let mut resolved_roots = Vec::new(); for root in workspace_roots { diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index fd9e93e189..f78457c515 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -628,7 +628,7 @@ pub(super) async fn handle_pending_thread_resume_request( } let config_snapshot = pending.config_snapshot; - let cwd = config_snapshot.cwd().clone(); + let cwd = thread_response_cwd(&config_snapshot); let ThreadConfigSnapshot { model, model_provider_id, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 58719d55ba..6c0e737bd0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1202,7 +1202,7 @@ impl ThreadRequestProcessor { &config_snapshot.permission_profile, config_snapshot.cwd().as_path(), ); - let cwd = config_snapshot.cwd().clone(); + let cwd = thread_response_cwd(&config_snapshot); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -2712,6 +2712,7 @@ impl ThreadRequestProcessor { &config_snapshot.permission_profile, config_snapshot.cwd().as_path(), ); + let cwd = thread_response_cwd(&config_snapshot); let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, ); @@ -2745,7 +2746,7 @@ impl ThreadRequestProcessor { model: session_configured.model, model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, - cwd: session_configured.cwd, + cwd, runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), @@ -3452,6 +3453,7 @@ impl ThreadRequestProcessor { &config_snapshot.permission_profile, config_snapshot.cwd().as_path(), ); + let cwd = thread_response_cwd(&config_snapshot); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); @@ -3460,7 +3462,7 @@ impl ThreadRequestProcessor { model: session_configured.model, model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, - cwd: session_configured.cwd, + cwd, runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), @@ -4320,7 +4322,7 @@ fn build_thread_from_snapshot( updated_at: now, status: ThreadStatus::NotLoaded, path, - cwd: config_snapshot.cwd().clone(), + cwd: thread_response_cwd(config_snapshot), cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0fb1320055..08d1816efb 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -189,13 +189,14 @@ pub(crate) fn thread_response_sandbox_policy( pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { + let cwd = thread_response_cwd(config_snapshot); ThreadSettings { - cwd: config_snapshot.cwd().clone(), + cwd: cwd.clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: thread_response_sandbox_policy( &config_snapshot.permission_profile, - config_snapshot.cwd().as_path(), + cwd.as_path(), ), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), @@ -212,6 +213,7 @@ pub(crate) fn thread_settings_from_config_snapshot( pub(crate) fn thread_settings_from_core_snapshot( snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, + config_snapshot: &ThreadConfigSnapshot, ) -> ThreadSettings { let codex_protocol::protocol::ThreadSettingsSnapshot { model, @@ -227,6 +229,7 @@ pub(crate) fn thread_settings_from_core_snapshot( personality, collaboration_mode, } = snapshot; + let cwd = thread_response_cwd_for_path(cwd, config_snapshot); let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { sandbox_policy, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 0b49647128..1377e3d9f3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -555,8 +555,10 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { vec![second_filtered_id.as_str(), first_filtered_id.as_str()] ); assert!(!filtered_ids.contains(&unfiltered_id.as_str())); - assert_eq!(data[0].cwd.as_path(), second_target_cwd.as_path()); - assert_eq!(data[1].cwd.as_path(), first_target_cwd.as_path()); + let expected_second_cwd = codex_utils_path::normalize_for_path_comparison(&second_target_cwd)?; + let expected_first_cwd = codex_utils_path::normalize_for_path_comparison(&first_target_cwd)?; + assert_eq!(data[0].cwd.as_path(), expected_second_cwd.as_path()); + assert_eq!(data[1].cwd.as_path(), expected_first_cwd.as_path()); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 272ecac79f..f7b70783ec 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -68,6 +68,8 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::symlink; use std::path::Path; use std::sync::Arc; use tempfile::TempDir; @@ -134,6 +136,109 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn local_thread_apis_return_one_canonical_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace_home = TempDir::new()?; + let workspace = workspace_home.path().join("CEO Core"); + let workspace_alias = workspace_home.path().join("CEO Core Alias"); + std::fs::create_dir(&workspace)?; + symlink(&workspace, &workspace_alias)?; + let canonical_cwd = workspace.canonicalize()?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace_alias.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, cwd, .. } = to_response(start_resp)?; + assert_eq!(cwd.as_path(), canonical_cwd); + assert_eq!(thread.cwd.as_path(), canonical_cwd); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Persist this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let thread_id = thread.id; + drop(mcp); + std::fs::remove_file(&workspace_alias)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(50), + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: true, + search_term: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, .. } = to_response(list_resp)?; + let listed = data + .iter() + .find(|thread| thread.id == thread_id) + .expect("thread/list should return the persisted thread"); + assert_eq!(listed.cwd.as_path(), canonical_cwd); + + for include_turns in [false, true] { + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response(read_resp)?; + assert_eq!(thread.cwd.as_path(), canonical_cwd); + } + + Ok(()) +} + #[tokio::test] async fn thread_read_can_include_turns() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index cf797ff6be..ba3ba42ca3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2230,7 +2230,8 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { ) .await??; let ThreadResumeResponse { cwd, .. } = to_response::(resume_resp)?; - assert_eq!(cwd, AbsolutePathBuf::from_absolute_path(codex_home.path())?); + let expected_cwd = codex_utils_path::normalize_for_path_comparison(codex_home.path())?; + assert_eq!(cwd.as_path(), expected_cwd.as_path()); let turn_id = mcp .send_turn_start_request(TurnStartParams { diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 98ad587326..e2800554f0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -43,10 +43,18 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> create_config_toml(codex_home.path(), &server.uri())?; write_models_cache(codex_home.path())?; let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + let workspace = codex_home.path().join("workspace"); + let workspace_child = workspace.join("child"); + std::fs::create_dir_all(&workspace_child)?; + let workspace_alias = workspace_child.join(".."); + let canonical_cwd = workspace.canonicalize()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let thread = start_thread(&mut mcp).await?.thread; + let thread = start_thread_with_cwd(&mut mcp, workspace_alias) + .await? + .thread; + assert_eq!(thread.cwd.as_path(), canonical_cwd); send_thread_settings_update( &mut mcp, @@ -67,6 +75,7 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> let updated = read_thread_settings_updated(&mut mcp).await?; assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.cwd.as_path(), canonical_cwd); assert_eq!(updated.thread_settings.model, model_id); assert_eq!( updated.thread_settings.service_tier.as_deref(), @@ -324,9 +333,24 @@ async fn start_text_turn(mcp: &mut TestAppServer, thread_id: String) -> Result<( } async fn start_thread(mcp: &mut TestAppServer) -> Result { + start_thread_with_params(mcp, /*cwd*/ None).await +} + +async fn start_thread_with_cwd( + mcp: &mut TestAppServer, + cwd: std::path::PathBuf, +) -> Result { + start_thread_with_params(mcp, Some(cwd.to_string_lossy().into_owned())).await +} + +async fn start_thread_with_params( + mcp: &mut TestAppServer, + cwd: Option, +) -> Result { let request_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + cwd, ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 6366c6fbca..e4ab621411 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -229,7 +229,8 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { .. } = to_response::(resp)?; - assert_eq!(response_cwd, cwd.abs()); + let canonical_cwd = codex_utils_path::normalize_for_path_comparison(&cwd)?; + assert_eq!(response_cwd.as_path(), canonical_cwd.as_path()); assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); Ok(()) diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 5acdcf09b6..7f6a9313c4 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -96,6 +96,10 @@ pub(super) fn touch_modified_time(path: &Path) -> std::io::Result<()> { OpenOptions::new().append(true).open(path)?.set_times(times) } +pub(super) fn normalize_thread_cwd(cwd: PathBuf) -> PathBuf { + codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd) +} + pub(super) fn stored_thread_from_rollout_item( item: ThreadItem, archived: bool, @@ -119,6 +123,7 @@ pub(super) fn stored_thread_from_rollout_item( .or_else(|| item.first_user_message.clone()) .unwrap_or_default(); let rollout_path = codex_rollout::plain_rollout_path(item.path.as_path()); + let cwd = normalize_thread_cwd(item.cwd.unwrap_or_default()); Some(StoredThread { thread_id, @@ -137,7 +142,7 @@ pub(super) fn stored_thread_from_rollout_item( created_at, updated_at, archived_at, - cwd: item.cwd.unwrap_or_default(), + cwd, cli_version: item.cli_version.unwrap_or_default(), source, thread_source: None, @@ -241,6 +246,9 @@ fn thread_id_from_rollout_path(path: &Path) -> Option { #[cfg(test)] mod tests { + #[cfg(unix)] + use std::os::unix::fs::symlink; + use codex_rollout::ThreadItem; use pretty_assertions::assert_eq; use uuid::Uuid; @@ -270,4 +278,34 @@ mod tests { ) ); } + + #[cfg(unix)] + #[test] + fn stored_thread_from_rollout_item_canonicalizes_cwd() { + let home = tempfile::tempdir().expect("temp dir"); + let target = home.path().join("target"); + let alias = home.path().join("alias"); + std::fs::create_dir(&target).expect("target dir"); + symlink(&target, &alias).expect("cwd symlink"); + let uuid = Uuid::from_u128(2); + + let thread = stored_thread_from_rollout_item( + ThreadItem { + path: home.path().join(format!("rollout-{uuid}.jsonl")), + cwd: Some(alias), + ..Default::default() + }, + /*archived*/ false, + "test-provider", + ) + .expect("stored thread"); + + assert_eq!(thread.cwd, target.canonicalize().expect("canonical target")); + } + + #[test] + fn normalize_thread_cwd_preserves_missing_path() { + let missing = PathBuf::from("/definitely/missing/codex-thread-cwd"); + assert_eq!(normalize_thread_cwd(missing.clone()), missing); + } } diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 24bc32993c..2f1bb3430c 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -15,6 +15,7 @@ use codex_state::ThreadMetadata; use super::LocalThreadStore; use super::helpers::distinct_thread_metadata_title; use super::helpers::git_info_from_parts; +use super::helpers::normalize_thread_cwd; use super::helpers::permission_profile_from_metadata_value; use super::helpers::rollout_path_is_archived; use super::helpers::set_thread_name_from_title; @@ -55,6 +56,13 @@ pub(super) async fn read_thread( && (params.include_archived || rollout_thread.archived_at.is_none()) && !rollout_thread.preview.is_empty() { + // SQLite observes and normalizes cwd updates as they are persisted. Prefer its + // stable value while it still resolves, so a missing or retargeted rollout alias + // cannot change the cwd returned by thread/read. If SQLite is stale or missing, + // retain the rollout cwd as before. + if codex_utils_path::normalize_for_path_comparison(thread.cwd.as_path()).is_ok() { + rollout_thread.cwd = thread.cwd.clone(); + } if thread.name.is_some() { rollout_thread.name = thread.name; } @@ -321,8 +329,9 @@ async fn stored_thread_from_sqlite_metadata( .clone() .or_else(|| metadata.first_user_message.clone()) .unwrap_or_default(); + let cwd = normalize_thread_cwd(metadata.cwd); let permission_profile = - permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path()); + permission_profile_from_metadata_value(&metadata.sandbox_policy, cwd.as_path()); StoredThread { thread_id: metadata.id, extra_config: None, @@ -341,7 +350,7 @@ async fn stored_thread_from_sqlite_metadata( created_at: metadata.created_at, updated_at: metadata.updated_at, archived_at: metadata.archived_at, - cwd: metadata.cwd, + cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), thread_source: metadata.thread_source, @@ -389,6 +398,7 @@ fn stored_thread_from_meta_line( .map(DateTime::::from) .unwrap_or(created_at); let rollout_path = codex_rollout::plain_rollout_path(path.as_path()); + let cwd = normalize_thread_cwd(meta_line.meta.cwd); StoredThread { thread_id: meta_line.meta.id, extra_config: None, @@ -407,7 +417,7 @@ fn stored_thread_from_meta_line( created_at, updated_at, archived_at: archived.then_some(updated_at), - cwd: meta_line.meta.cwd, + cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, thread_source: meta_line.meta.thread_source, @@ -447,7 +457,8 @@ fn parse_rfc3339_non_optional(value: &str) -> Option> { #[cfg(test)] mod tests { use std::io::Write; - use std::path::PathBuf; + #[cfg(unix)] + use std::os::unix::fs::symlink; use chrono::Utc; use codex_protocol::ThreadId; @@ -806,7 +817,9 @@ mod tests { std::fs::create_dir_all(&day_dir).expect("sessions dir"); let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl")); let mut file = std::fs::File::create(&rollout_path).expect("session file"); - let rollout_cwd = PathBuf::from("/"); + let rollout_cwd = home.path().join("rollout-workspace"); + std::fs::create_dir(&rollout_cwd).expect("rollout workspace dir"); + let expected_rollout_cwd = normalize_thread_cwd(rollout_cwd.clone()); let meta = serde_json::json!({ "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", @@ -863,7 +876,7 @@ mod tests { assert_eq!(thread.preview, "Hello from rollout"); assert_eq!(thread.name, Some("Saved title".to_string())); assert_eq!(thread.model_provider, "rollout-provider"); - assert_eq!(thread.cwd, rollout_cwd); + assert_eq!(thread.cwd, expected_rollout_cwd); let legacy_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -874,11 +887,82 @@ mod tests { thread.permission_profile, PermissionProfile::from_legacy_sandbox_policy_for_cwd( &legacy_policy, - rollout_cwd.as_path() + expected_rollout_cwd.as_path() ) ); } + #[cfg(unix)] + #[tokio::test] + async fn read_thread_returns_same_canonical_cwd_from_rollout_and_sqlite() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); + let uuid = Uuid::from_u128(240); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let target_cwd = home.path().join("workspace"); + let alias_cwd = home.path().join("workspace-alias"); + std::fs::create_dir(&target_cwd).expect("workspace dir"); + symlink(&target_cwd, &alias_cwd).expect("workspace alias"); + + let day_dir = home.path().join("sessions/2025/01/03"); + std::fs::create_dir_all(&day_dir).expect("sessions dir"); + let rollout_path = day_dir.join(format!("rollout-2025-01-03T12-00-00-{uuid}.jsonl")); + let mut file = std::fs::File::create(&rollout_path).expect("session file"); + let meta = serde_json::json!({ + "timestamp": "2025-01-03T12:00:00Z", + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": "2025-01-03T12:00:00Z", + "cwd": alias_cwd, + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "rollout-provider" + }, + }); + writeln!(file, "{meta}").expect("write session meta"); + let user_event = serde_json::json!({ + "timestamp": "2025-01-03T12:00:00Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "Hello from rollout", + "kind": "plain", + }, + }); + writeln!(file, "{user_event}").expect("write user event"); + + let mut builder = + ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); + builder.cwd = target_cwd.clone(); + runtime + .upsert_thread(&builder.build(config.default_model_provider_id.as_str())) + .await + .expect("state db upsert should succeed"); + + let canonical_cwd = target_cwd.canonicalize().expect("canonical workspace"); + std::fs::remove_file(&alias_cwd).expect("remove workspace alias"); + for include_history in [false, true] { + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history, + }) + .await + .expect("read thread"); + assert_eq!(thread.cwd, canonical_cwd); + } + } + #[tokio::test] async fn read_thread_uses_legacy_thread_name_when_sqlite_title_is_missing() { let home = TempDir::new().expect("temp dir"); @@ -1112,7 +1196,10 @@ mod tests { ); assert!(thread.updated_at >= thread.created_at); assert_eq!(thread.archived_at, None); - assert_eq!(thread.cwd, home.path()); + assert_eq!( + thread.cwd, + home.path().canonicalize().expect("canonical cwd") + ); assert_eq!(thread.cli_version, "test_version"); assert_eq!(thread.source, SessionSource::Cli); let history = thread.history.expect("history should load"); diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 7754b1a841..b8344bad4f 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -18,6 +18,7 @@ use tracing::warn; use super::LocalThreadStore; use super::helpers::git_info_from_parts; +use super::helpers::normalize_thread_cwd; use super::helpers::permission_profile_to_metadata_value; use super::live_writer; use crate::GitInfoPatch; @@ -225,7 +226,11 @@ async fn apply_metadata_update( builder.agent_nickname = patch.agent_nickname.clone().flatten(); builder.agent_role = patch.agent_role.clone().flatten(); builder.agent_path = patch.agent_path.clone().flatten(); - builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default(); + builder.cwd = patch + .cwd + .clone() + .map(normalize_thread_cwd) + .unwrap_or_default(); builder.cli_version = patch.cli_version.clone(); let mut metadata = builder.build(store.config.default_model_provider_id.as_str()); if rollout_path_archived { @@ -276,7 +281,7 @@ async fn apply_metadata_update( metadata.agent_path = agent_path; } if let Some(cwd) = patch.cwd { - metadata.cwd = normalize_cwd(cwd); + metadata.cwd = normalize_thread_cwd(cwd); } if let Some(cli_version) = patch.cli_version { metadata.cli_version = cli_version; @@ -404,10 +409,6 @@ fn enum_to_string(value: &T) -> String { } } -fn normalize_cwd(cwd: PathBuf) -> PathBuf { - codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd) -} - async fn apply_thread_git_info( store: &LocalThreadStore, thread_id: ThreadId,