Compare commits

...

2 Commits

Author SHA1 Message Date
Felipe Coury
b4945c7bb0 Fix remote TUI cwd handling per thread 2026-05-09 12:31:43 -03:00
Eric Traut
a9bbaebff9 Fix remote TUI cwd decoding
Fixes #21357
2026-05-09 12:31:11 -03:00
7 changed files with 562 additions and 106 deletions

View File

@@ -63,7 +63,7 @@ pub struct TurnStartParams {
pub environments: Option<Vec<TurnEnvironmentParams>>,
/// Override the working directory for this turn and subsequent turns.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
pub cwd: Option<String>,
/// Override the approval policy for this turn and subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]

View File

@@ -1262,7 +1262,7 @@ fn live_elicitation_timeout_pause(
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
effort: Some(ReasoningEffort::High),
cwd: Some(workspace),
cwd: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
})?;
println!("< turn/start response: {turn_response:?}");

View File

@@ -374,7 +374,7 @@ impl TurnRequestProcessor {
));
}
let cwd = params.cwd;
let cwd = params.cwd.map(PathBuf::from);
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
let approvals_reviewer = params
.approvals_reviewer

View File

@@ -774,7 +774,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
input: items.into_iter().map(Into::into).collect(),
responsesapi_client_metadata: None,
environments: None,
cwd: Some(default_cwd),
cwd: Some(default_cwd.to_string_lossy().into_owned()),
approval_policy: Some(default_approval_policy.into()),
approvals_reviewer: None,
sandbox_policy: None,

View File

@@ -13,9 +13,12 @@ use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::de::DeserializeOwned;
impl App {
pub(super) fn fetch_mcp_inventory(
@@ -81,8 +84,9 @@ impl App {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let cwd = self.config.cwd.to_path_buf();
let is_remote = app_server.is_remote();
tokio::spawn(async move {
let result = fetch_skills_list(request_handle, cwd)
let result = fetch_skills_list(request_handle, cwd, is_remote)
.await
.map_err(|err| format!("{err:#}"));
app_event_tx.send(AppEvent::SkillsListLoaded { result });
@@ -94,8 +98,9 @@ impl App {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let cwd = self.config.cwd.to_path_buf();
let is_remote = app_server.is_remote();
tokio::spawn(async move {
let result = fetch_hooks_list(request_handle, cwd.clone()).await;
let result = fetch_hooks_list(request_handle, cwd.clone(), is_remote).await;
let response = match result {
Ok(response) => response,
Err(err) => {
@@ -133,8 +138,9 @@ impl App {
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let is_remote = app_server.is_remote();
tokio::spawn(async move {
let result = fetch_plugins_list(request_handle, cwd.clone())
let result = fetch_plugins_list(request_handle, cwd.clone(), is_remote)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::PluginsLoaded { cwd, result });
@@ -144,8 +150,9 @@ impl App {
pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let is_remote = app_server.is_remote();
tokio::spawn(async move {
let result = fetch_hooks_list(request_handle, cwd.clone())
let result = fetch_hooks_list(request_handle, cwd.clone(), is_remote)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::HooksLoaded { cwd, result });
@@ -635,41 +642,91 @@ pub(super) async fn send_add_credits_nudge_email(
Ok(response.status)
}
async fn request_typed_with_local_path_base<T>(
request_handle: &AppServerRequestHandle,
is_remote: bool,
method: &'static str,
request: ClientRequest,
local_path_base: &std::path::Path,
) -> std::result::Result<T, TypedRequestError>
where
T: DeserializeOwned,
{
if !is_remote {
return request_handle.request_typed(request).await;
}
let response =
request_handle
.request(request)
.await
.map_err(|source| TypedRequestError::Transport {
method: method.to_string(),
source,
})?;
let result = response.map_err(|source| TypedRequestError::Server {
method: method.to_string(),
source,
})?;
let path_base_guard = AbsolutePathBufGuard::new(local_path_base);
let response =
serde_json::from_value(result).map_err(|source| TypedRequestError::Deserialize {
method: method.to_string(),
source,
})?;
drop(path_base_guard);
Ok(response)
}
pub(super) async fn fetch_skills_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
is_remote: bool,
) -> Result<SkillsListResponse> {
let local_path_base = cwd.clone();
let request_id = RequestId::String(format!("startup-skills-list-{}", Uuid::new_v4()));
// Use the cloneable request handle so startup can issue this RPC from a background task without
// extending a borrow of `AppServerSession` across the first frame render.
request_handle
.request_typed(ClientRequest::SkillsList {
request_typed_with_local_path_base(
&request_handle,
is_remote,
"skills/list",
ClientRequest::SkillsList {
request_id,
params: SkillsListParams {
cwds: vec![cwd],
force_reload: true,
},
})
.await
.wrap_err("skills/list failed in TUI")
},
local_path_base.as_path(),
)
.await
.wrap_err("skills/list failed in TUI")
}
pub(super) async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
is_remote: bool,
) -> Result<PluginListResponse> {
let local_path_base = cwd.clone();
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?;
let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4()));
let mut response = request_handle
.request_typed(ClientRequest::PluginList {
let mut response = request_typed_with_local_path_base(
&request_handle,
is_remote,
"plugin/list",
ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
marketplace_kinds: None,
},
})
.await
.wrap_err("plugin/list failed in TUI")?;
},
local_path_base.as_path(),
)
.await
.wrap_err("plugin/list failed in TUI")?;
hide_cli_only_plugin_marketplaces(&mut response);
Ok(response)
}
@@ -677,15 +734,22 @@ pub(super) async fn fetch_plugins_list(
pub(super) async fn fetch_hooks_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
is_remote: bool,
) -> Result<HooksListResponse> {
let local_path_base = cwd.clone();
let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::HooksList {
request_typed_with_local_path_base(
&request_handle,
is_remote,
"hooks/list",
ClientRequest::HooksList {
request_id,
params: HooksListParams { cwds: vec![cwd] },
})
.await
.wrap_err("hooks/list failed in TUI")
},
local_path_base.as_path(),
)
.await
.wrap_err("hooks/list failed in TUI")
}
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];

View File

@@ -616,12 +616,19 @@ impl App {
Ok(true)
}
AppCommand::ListSkills { cwds, force_reload } => {
let local_path_base = cwds
.first()
.map(std::path::PathBuf::as_path)
.unwrap_or_else(|| self.config.cwd.as_path());
self.handle_skills_list_result(
app_server
.skills_list(codex_app_server_protocol::SkillsListParams {
cwds: cwds.clone(),
force_reload: *force_reload,
})
.skills_list(
codex_app_server_protocol::SkillsListParams {
cwds: cwds.clone(),
force_reload: *force_reload,
},
local_path_base,
)
.await,
"failed to refresh skills",
);

View File

@@ -114,16 +114,55 @@ use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
}
async fn request_typed_with_local_path_base<T>(
client: &AppServerClient,
is_remote: bool,
method: &'static str,
request: ClientRequest,
local_path_base: &Path,
) -> std::result::Result<T, TypedRequestError>
where
T: DeserializeOwned,
{
if !is_remote {
return client.request_typed(request).await;
}
let response =
client
.request(request)
.await
.map_err(|source| TypedRequestError::Transport {
method: method.to_string(),
source,
})?;
let result = response.map_err(|source| TypedRequestError::Server {
method: method.to_string(),
source,
})?;
let path_base_guard = AbsolutePathBufGuard::new(local_path_base);
let response =
serde_json::from_value(result).map_err(|source| TypedRequestError::Deserialize {
method: method.to_string(),
source,
})?;
drop(path_base_guard);
Ok(response)
}
/// Data collected during the TUI bootstrap phase that the main event loop
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
///
@@ -148,7 +187,32 @@ pub(crate) struct AppServerBootstrap {
pub(crate) struct AppServerSession {
client: AppServerClient,
next_request_id: i64,
remote_cwd_override: Option<PathBuf>,
remote_cwds: RemoteCwds,
}
#[derive(Default)]
struct RemoteCwds {
explicit_cwd: Option<String>,
by_thread: HashMap<ThreadId, String>,
}
impl RemoteCwds {
fn with_explicit_cwd(mut self, explicit_cwd: Option<PathBuf>) -> Self {
self.explicit_cwd = explicit_cwd.map(|cwd| cwd.to_string_lossy().into_owned());
self
}
fn explicit_cwd(&self) -> Option<&str> {
self.explicit_cwd.as_deref()
}
fn cwd_for_thread(&self, thread_id: &ThreadId) -> Option<&str> {
self.by_thread.get(thread_id).map(String::as_str)
}
fn record_thread_cwd(&mut self, thread_id: ThreadId, cwd: String) {
self.by_thread.insert(thread_id, cwd);
}
}
#[derive(Clone, Copy)]
@@ -176,17 +240,17 @@ impl AppServerSession {
Self {
client,
next_request_id: 1,
remote_cwd_override: None,
remote_cwds: RemoteCwds::default(),
}
}
pub(crate) fn with_remote_cwd_override(mut self, remote_cwd_override: Option<PathBuf>) -> Self {
self.remote_cwd_override = remote_cwd_override;
self.remote_cwds = self.remote_cwds.with_explicit_cwd(remote_cwd_override);
self
}
pub(crate) fn remote_cwd_override(&self) -> Option<&std::path::Path> {
self.remote_cwd_override.as_deref()
self.remote_cwds.explicit_cwd().map(std::path::Path::new)
}
pub(crate) fn is_remote(&self) -> bool {
@@ -336,22 +400,26 @@ impl AppServerSession {
session_start_source: Option<ThreadStartSource>,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let thread_params_mode = self.thread_params_mode();
let response: ThreadStartResponse = self
.client
.request_typed(ClientRequest::ThreadStart {
request_id,
params: thread_start_params_from_config(
config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
session_start_source,
),
})
.request_thread_lifecycle_response(
"thread/start",
ClientRequest::ThreadStart {
request_id,
params: thread_start_params_from_config(
config,
thread_params_mode,
self.remote_cwds.explicit_cwd(),
session_start_source,
),
},
config,
)
.await
.map_err(|err| {
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
})?;
started_thread_from_start_response(response, config, self.thread_params_mode()).await
started_thread_from_start_response(response, config, thread_params_mode).await
}
pub(crate) async fn resume_thread(
@@ -360,17 +428,21 @@ impl AppServerSession {
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let thread_params_mode = self.thread_params_mode();
let response: ThreadResumeResponse = self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
),
})
.request_thread_lifecycle_response(
"thread/resume",
ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config.clone(),
thread_id,
thread_params_mode,
self.remote_cwds.cwd_for_thread(&thread_id),
),
},
&config,
)
.await
.map_err(|err| {
bootstrap_request_error("thread/resume failed during TUI bootstrap", err)
@@ -379,8 +451,7 @@ impl AppServerSession {
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
.await;
let mut started =
started_thread_from_resume_response(response, &config, self.thread_params_mode())
.await?;
started_thread_from_resume_response(response, &config, thread_params_mode).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
@@ -391,17 +462,21 @@ impl AppServerSession {
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let thread_params_mode = self.thread_params_mode();
let response: ThreadForkResponse = self
.client
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
),
})
.request_thread_lifecycle_response(
"thread/fork",
ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
thread_id,
thread_params_mode,
self.remote_cwds.cwd_for_thread(&thread_id),
),
},
&config,
)
.await
.map_err(|err| {
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
@@ -410,7 +485,7 @@ impl AppServerSession {
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
.await;
let mut started =
started_thread_from_fork_response(response, &config, self.thread_params_mode()).await?;
started_thread_from_fork_response(response, &config, thread_params_mode).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
@@ -422,6 +497,58 @@ impl AppServerSession {
}
}
/// Sends a thread lifecycle request as raw JSON so the TUI can capture the
/// raw cwd before typed deserialization resolves paths against a local base.
async fn request_thread_lifecycle_response<T>(
&mut self,
method: &'static str,
request: ClientRequest,
config: &Config,
) -> std::result::Result<T, TypedRequestError>
where
T: DeserializeOwned,
{
let response =
self.client
.request(request)
.await
.map_err(|source| TypedRequestError::Transport {
method: method.to_string(),
source,
})?;
let result = response.map_err(|source| TypedRequestError::Server {
method: method.to_string(),
source,
})?;
let raw_cwd = self
.is_remote()
.then(|| thread_lifecycle_response_cwd(&result))
.flatten();
let raw_thread_id = raw_cwd
.as_ref()
.and_then(|_| thread_lifecycle_response_thread_id(&result));
let path_base_guard = AbsolutePathBufGuard::new(config.cwd.as_path());
let response =
serde_json::from_value(result).map_err(|source| TypedRequestError::Deserialize {
method: method.to_string(),
source,
})?;
drop(path_base_guard);
if let (Some(raw_thread_id), Some(raw_cwd)) = (raw_thread_id, raw_cwd) {
match ThreadId::from_string(&raw_thread_id) {
Ok(thread_id) => self.remote_cwds.record_thread_cwd(thread_id, raw_cwd),
Err(err) => {
tracing::warn!(
thread_id = %raw_thread_id,
%err,
"Failed to record remote cwd for app-server thread"
);
}
}
}
Ok(response)
}
async fn fork_parent_title_from_app_server(
&mut self,
forked_from_id: Option<&str>,
@@ -536,36 +663,35 @@ impl AppServerSession {
output_schema: Option<serde_json::Value>,
) -> Result<TurnStartResponse> {
let request_id = self.next_request_id();
let (sandbox_policy, permissions) = turn_permissions_overrides(
let local_path_base = cwd.clone();
let remote_cwd = self.remote_cwds.cwd_for_thread(&thread_id);
let params = turn_start_params(
thread_id,
items,
cwd,
&permission_profile,
active_permission_profile,
cwd.as_path(),
self.thread_params_mode(),
remote_cwd,
approval_policy,
approvals_reviewer,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
output_schema,
);
self.client
.request_typed(ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread_id.to_string(),
input: items,
responsesapi_client_metadata: None,
environments: None,
cwd: Some(cwd),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy,
permissions,
model: Some(model),
service_tier,
effort,
summary,
personality,
output_schema,
collaboration_mode,
},
})
.await
.wrap_err("turn/start failed in TUI")
request_typed_with_local_path_base(
&self.client,
self.is_remote(),
"turn/start",
ClientRequest::TurnStart { request_id, params },
local_path_base.as_path(),
)
.await
.wrap_err("turn/start failed in TUI")
}
pub(crate) async fn turn_interrupt(
@@ -861,12 +987,18 @@ impl AppServerSession {
pub(crate) async fn skills_list(
&mut self,
params: SkillsListParams,
local_path_base: &Path,
) -> Result<SkillsListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::SkillsList { request_id, params })
.await
.wrap_err("skills/list failed in TUI")
request_typed_with_local_path_base(
&self.client,
self.is_remote(),
"skills/list",
ClientRequest::SkillsList { request_id, params },
local_path_base,
)
.await
.wrap_err("skills/list failed in TUI")
}
pub(crate) async fn reload_user_config(&mut self) -> Result<()> {
@@ -1172,7 +1304,7 @@ fn permissions_selection_from_config(
fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
remote_cwd_override: Option<&str>,
session_start_source: Option<ThreadStartSource>,
) -> ThreadStartParams {
let permissions = permissions_selection_from_config(config, thread_params_mode);
@@ -1206,7 +1338,7 @@ fn thread_resume_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
remote_cwd_override: Option<&str>,
) -> ThreadResumeParams {
let permissions = permissions_selection_from_config(&config, thread_params_mode);
let sandbox = permissions
@@ -1237,7 +1369,7 @@ fn thread_fork_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
remote_cwd_override: Option<&str>,
) -> ThreadForkParams {
let permissions = permissions_selection_from_config(&config, thread_params_mode);
let sandbox = permissions
@@ -1271,13 +1403,89 @@ fn thread_fork_params_from_config(
fn thread_cwd_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
remote_cwd_override: Option<&str>,
) -> Option<String> {
match thread_params_mode {
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
ThreadParamsMode::Remote => {
remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
}
ThreadParamsMode::Remote => remote_cwd_override.map(ToString::to_string),
}
}
fn thread_lifecycle_response_cwd(result: &serde_json::Value) -> Option<String> {
result
.get("cwd")
.and_then(serde_json::Value::as_str)
.or_else(|| {
result
.get("thread")
.and_then(|thread| thread.get("cwd"))
.and_then(serde_json::Value::as_str)
})
.filter(|cwd| !cwd.is_empty())
.map(ToString::to_string)
}
fn thread_lifecycle_response_thread_id(result: &serde_json::Value) -> Option<String> {
result
.get("thread")
.and_then(|thread| thread.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|thread_id| !thread_id.is_empty())
.map(ToString::to_string)
}
#[expect(
clippy::too_many_arguments,
reason = "turn/start mirrors the app-server request shape"
)]
fn turn_start_params(
thread_id: ThreadId,
items: Vec<UserInput>,
cwd: PathBuf,
permission_profile: &PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
thread_params_mode: ThreadParamsMode,
remote_cwd: Option<&str>,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<String>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
output_schema: Option<serde_json::Value>,
) -> TurnStartParams {
let remote_cwd_path = remote_cwd.map(Path::new);
let permission_cwd = remote_cwd_path.unwrap_or(cwd.as_path());
let (sandbox_policy, permissions) = turn_permissions_overrides(
permission_profile,
active_permission_profile,
permission_cwd,
thread_params_mode,
);
let cwd = match thread_params_mode {
ThreadParamsMode::Embedded => Some(cwd.to_string_lossy().into_owned()),
ThreadParamsMode::Remote => remote_cwd.map(ToString::to_string),
};
TurnStartParams {
thread_id: thread_id.to_string(),
input: items,
responsesapi_client_metadata: None,
environments: None,
cwd,
approval_policy: Some(approval_policy),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy,
permissions,
model: Some(model),
service_tier,
effort,
summary,
personality,
output_schema,
collaboration_mode,
}
}
@@ -1524,6 +1732,7 @@ mod tests {
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
async fn build_config(temp_dir: &TempDir) -> Config {
@@ -1534,6 +1743,182 @@ mod tests {
.expect("config should build")
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct TestThreadLifecycleResponse {
cwd: AbsolutePathBuf,
instruction_sources: Vec<AbsolutePathBuf>,
thread: TestThreadLifecycleThread,
}
#[derive(serde::Deserialize)]
struct TestThreadLifecycleThread {
cwd: AbsolutePathBuf,
}
#[tokio::test]
async fn thread_lifecycle_response_preserves_raw_cwd_when_paths_need_local_base() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let (server_cwd, instruction_source) = if cfg!(windows) {
("/home/remote/project", "/home/remote/project/AGENTS.md")
} else {
(
r"C:\Users\remote\project",
r"C:\Users\remote\project\AGENTS.md",
)
};
let response = json!({
"cwd": server_cwd,
"instructionSources": [instruction_source],
"thread": {
"id": thread_id.to_string(),
"cwd": server_cwd
}
});
assert!(
serde_json::from_value::<TestThreadLifecycleResponse>(response.clone()).is_err(),
"remote cwd should not decode without a local base path"
);
let raw_cwd = thread_lifecycle_response_cwd(&response);
let raw_thread_id = thread_lifecycle_response_thread_id(&response);
let path_base_guard = AbsolutePathBufGuard::new(config.cwd.as_path());
let decoded = serde_json::from_value::<TestThreadLifecycleResponse>(response)
.expect("remote thread response should decode through local path base");
drop(path_base_guard);
assert_eq!(raw_cwd.as_deref(), Some(server_cwd));
assert_eq!(raw_thread_id, Some(thread_id.to_string()));
assert!(decoded.cwd.as_path().is_absolute());
assert!(decoded.thread.cwd.as_path().is_absolute());
assert_eq!(decoded.instruction_sources.len(), 1);
assert!(decoded.instruction_sources[0].as_path().is_absolute());
}
#[test]
fn remote_cwds_are_thread_scoped() {
let thread_a = ThreadId::new();
let thread_b = ThreadId::new();
let mut remote_cwds =
RemoteCwds::default().with_explicit_cwd(Some(PathBuf::from("/repo/default")));
remote_cwds.record_thread_cwd(thread_a, "/repo/a".to_string());
remote_cwds.record_thread_cwd(thread_b, "/repo/b".to_string());
assert_eq!(remote_cwds.explicit_cwd(), Some("/repo/default"));
assert_eq!(remote_cwds.cwd_for_thread(&thread_a), Some("/repo/a"));
assert_eq!(remote_cwds.cwd_for_thread(&thread_b), Some("/repo/b"));
}
#[test]
fn remote_turn_start_uses_thread_scoped_cwd() {
let thread_a = ThreadId::new();
let thread_b = ThreadId::new();
let mut remote_cwds = RemoteCwds::default();
remote_cwds.record_thread_cwd(thread_a, "/repo/a".to_string());
remote_cwds.record_thread_cwd(thread_b, "/repo/b".to_string());
let params = turn_start_params(
thread_a,
Vec::new(),
test_path_buf("/local/project").abs().to_path_buf(),
&PermissionProfile::read_only(),
/*active_permission_profile*/ None,
ThreadParamsMode::Remote,
remote_cwds.cwd_for_thread(&thread_a),
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
"gpt-5.4".to_string(),
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
/*output_schema*/ None,
);
assert_eq!(params.cwd.as_deref(), Some("/repo/a"));
}
#[test]
fn remote_turn_start_omits_cwd_when_thread_cwd_is_unknown() {
let params = turn_start_params(
ThreadId::new(),
Vec::new(),
test_path_buf("/local/project").abs().to_path_buf(),
&PermissionProfile::read_only(),
/*active_permission_profile*/ None,
ThreadParamsMode::Remote,
/*remote_cwd*/ None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
"gpt-5.4".to_string(),
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
/*output_schema*/ None,
);
assert_eq!(params.cwd, None);
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct TestSkillsListResponse {
data: Vec<TestSkillsListEntry>,
}
#[derive(serde::Deserialize)]
struct TestSkillsListEntry {
cwd: AbsolutePathBuf,
skills: Vec<TestSkillMetadata>,
}
#[derive(serde::Deserialize)]
struct TestSkillMetadata {
path: AbsolutePathBuf,
}
#[test]
fn skills_list_response_decodes_with_local_path_base() {
let (server_cwd, skill_path) = if cfg!(windows) {
(
"/home/remote/project",
"/home/remote/project/.codex/skills/demo/SKILL.md",
)
} else {
(
r"C:\Users\remote\project",
r"C:\Users\remote\project\.codex\skills\demo\SKILL.md",
)
};
let response = json!({
"data": [{
"cwd": server_cwd,
"skills": [{
"path": skill_path
}]
}]
});
assert!(
serde_json::from_value::<TestSkillsListResponse>(response.clone()).is_err(),
"remote skills/list paths should not decode without a local base path"
);
let local_base = test_path_buf("/local/project").abs();
let path_base_guard = AbsolutePathBufGuard::new(local_base.as_path());
let decoded = serde_json::from_value::<TestSkillsListResponse>(response)
.expect("skills/list should decode through a local path base");
drop(path_base_guard);
assert!(decoded.data[0].cwd.as_path().is_absolute());
assert!(decoded.data[0].skills[0].path.as_path().is_absolute());
}
#[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
@@ -1751,7 +2136,7 @@ mod tests {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let remote_cwd = PathBuf::from("repo/on/server");
let remote_cwd = "repo/on/server";
let expected_sandbox = sandbox_mode_from_permission_profile(
&config.permissions.permission_profile(),
config.cwd.as_path(),
@@ -1760,20 +2145,20 @@ mod tests {
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
Some(remote_cwd),
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
Some(remote_cwd),
);
let fork = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
Some(remote_cwd),
);
assert_eq!(start.cwd.as_deref(), Some("repo/on/server"));