diff --git a/codex-rs/app-server-client/src/cloud.rs b/codex-rs/app-server-client/src/cloud.rs index 03553d14db..1a356ece4c 100644 --- a/codex-rs/app-server-client/src/cloud.rs +++ b/codex-rs/app-server-client/src/cloud.rs @@ -19,6 +19,12 @@ use tokio::sync::oneshot; /// Unsupported operations must return an explicit error; they must not claim success. pub enum CloudOperation { Request(Box), + /// Host project metadata stays on the private transport, outside the app-server wire. + StartProjectThread { + request_id: RequestId, + params: Box, + project: codex_cloud_client::ProjectEnvironment, + }, SubmitUserMessage(Box), Notify(ClientNotification), Resolve { @@ -124,6 +130,20 @@ impl CloudClient { } impl CloudRequestHandle { + pub(crate) async fn start_project_thread( + &self, + request_id: RequestId, + params: codex_app_server_protocol::ThreadStartParams, + project: codex_cloud_client::ProjectEnvironment, + ) -> io::Result { + self.send(CloudOperation::StartProjectThread { + request_id, + params: Box::new(params), + project, + }) + .await + } + /// Submit once with a stable message ID and saved thread settings. The server /// starts or steers; an error can mean admission is unknown. Never auto-resend. pub async fn submit_user_message( diff --git a/codex-rs/app-server-client/src/cloud_backend/mod.rs b/codex-rs/app-server-client/src/cloud_backend/mod.rs index 225a1f9c6c..94d584482f 100644 --- a/codex-rs/app-server-client/src/cloud_backend/mod.rs +++ b/codex-rs/app-server-client/src/cloud_backend/mod.rs @@ -180,7 +180,21 @@ async fn run_worker( let _ = command.response.send(Ok(Ok(Value::Null))); return; } - CloudOperation::Request(request) => Ok((*request, cloud::SubmissionMode::StartIfIdle)), + CloudOperation::Request(request) => { + Ok((*request, cloud::SubmissionMode::StartIfIdle, None)) + } + CloudOperation::StartProjectThread { + request_id, + params, + project, + } => Ok(( + protocol::ClientRequest::ThreadStart { + request_id, + params: *params, + }, + cloud::SubmissionMode::StartIfIdle, + Some(project), + )), CloudOperation::SubmitUserMessage(params) => Ok(( protocol::ClientRequest::TurnStart { // Native admission is keyed by the caller's stable message ID. @@ -188,6 +202,7 @@ async fn run_worker( params: *params, }, cloud::SubmissionMode::Steer, + None, )), CloudOperation::Notify(_) => { Err(eyre!("Cloud client notifications are not supported yet.")) @@ -197,9 +212,9 @@ async fn run_worker( )), }; let result = match request { - Ok((request, mode)) => tokio::select! { + Ok((request, mode, project)) => tokio::select! { _ = output.closed() => return, - result = handle_request(request, mode, requests, replies, output) => result, + result = handle_request(request, mode, project, requests, replies, output) => result, }, Err(error) => Err(error), }; @@ -231,6 +246,7 @@ async fn run_worker( async fn handle_request( request: protocol::ClientRequest, mode: cloud::SubmissionMode, + project: Option, requests: &mut requests::Requests, replies: &mut Replies, output: &mpsc::UnboundedSender, @@ -256,7 +272,7 @@ async fn handle_request( replies.admit_turn(¶ms.thread_id, output)?; } match replies - .while_listening(requests.handle(request, mode, output), output) + .while_listening(requests.handle(request, mode, project, output), output) .await? { requests::Response::Value(value) => Ok(value), diff --git a/codex-rs/app-server-client/src/cloud_backend/requests.rs b/codex-rs/app-server-client/src/cloud_backend/requests.rs index 6d5814be45..d15943d23b 100644 --- a/codex-rs/app-server-client/src/cloud_backend/requests.rs +++ b/codex-rs/app-server-client/src/cloud_backend/requests.rs @@ -54,6 +54,7 @@ impl Requests { &mut self, request: protocol::ClientRequest, mode: cloud::SubmissionMode, + project: Option, output: &mpsc::UnboundedSender, ) -> Result { use protocol::ClientRequest as Request; @@ -79,7 +80,7 @@ impl Requests { "Cloud conversations start with server defaults. Custom settings are not supported yet." ); } - let settings = self.client.prewarm_default_thread(model, provider).await?; + let settings = self.client.prewarm_thread(model, provider, project).await?; return attach_response(settings); } Request::ThreadResume { params, .. } => { diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index d47c1ff152..5b011863f1 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -22,6 +22,7 @@ mod cloud; pub mod cloud_backend; +pub use codex_cloud_client::ProjectEnvironment; mod path; mod remote; mod remote_websocket; @@ -713,6 +714,27 @@ impl InProcessAppServerRequestHandle { } impl AppServerRequestHandle { + /// Starts a native cloud thread with host project metadata without adding wire fields. + pub async fn start_cloud_project_thread( + &self, + request_id: RequestId, + params: codex_app_server_protocol::ThreadStartParams, + project: codex_cloud_client::ProjectEnvironment, + ) -> Result { + let response = match self { + Self::Cloud(handle) => { + handle + .start_project_thread(request_id, params, project) + .await + } + Self::InProcess(_) | Self::Remote(_) => Err(IoError::new( + std::io::ErrorKind::Unsupported, + "Project checkout requires the native cloud transport.", + )), + }; + decode_typed_response("thread/start", response) + } + pub(crate) async fn resolve_server_request( &self, request_id: RequestId, diff --git a/codex-rs/cloud-client/src/client_tests.rs b/codex-rs/cloud-client/src/client_tests.rs index 979f861a79..f333509f7e 100644 --- a/codex-rs/cloud-client/src/client_tests.rs +++ b/codex-rs/cloud-client/src/client_tests.rs @@ -231,6 +231,55 @@ async fn activation_preserves_native_settings_and_thread_metadata() { assert!(server.requests.try_recv().is_err()); } +#[tokio::test] +async fn prewarm_encodes_blank_and_project_managed_environments() { + let mut fixture = fixture_responses(vec![ + response(vec![frame(&[])], tonic::Status::ok("")), + response(vec![frame(&[])], tonic::Status::ok("")), + ]) + .await; + + assert!(matches!( + fixture + .client + .prewarm_default_thread(/*model*/ None, /*model_provider*/ None) + .await, + Err(Error::InvalidResponse) + )); + assert_request(&mut fixture, "Prewarm", b"\x52\x04\x0a\x02\x0a\x00").await; + + assert!(matches!( + fixture + .client + .prewarm_thread( + /*model*/ None, + /*model_provider*/ None, + Some(ProjectEnvironment { + name: "App".to_owned(), + cwd: Some("/workspace/acme/app".to_owned()), + origin_urls: vec![ + "git@github.com:acme/app.git".to_owned(), + "https://github.com/acme/lib.git".to_owned(), + ], + }), + ) + .await, + Err(Error::InvalidResponse) + )); + let expected = [ + b"\x52\x73\x0a\x71\x0a\x6f\x0a\x0d" as &[u8], + b"asenvcfg_base", + b"\x12\x13/workspace/acme/app", + b"\x1a\x49\x0a\x42\x0a\x1d\x2a\x1b", + b"git@github.com:acme/app.git", + b"\x0a\x21\x2a\x1f", + b"https://github.com/acme/lib.git", + b"\x12\x03App", + ] + .concat(); + assert_request(&mut fixture, "Prewarm", &expected).await; +} + #[tokio::test] async fn prewarm_uses_managed_defaults_and_preserves_resolved_settings() { // PrewarmThread uses Thread field numbers but omits turns entirely. diff --git a/codex-rs/cloud-client/src/lib.rs b/codex-rs/cloud-client/src/lib.rs index c6b35c337e..e96815509d 100644 --- a/codex-rs/cloud-client/src/lib.rs +++ b/codex-rs/cloud-client/src/lib.rs @@ -56,6 +56,7 @@ pub use items::CommandSummary; pub use items::ItemLifecycle; pub use items::ItemStatus; pub use items::ItemSummary; +pub use prewarm::ProjectEnvironment; pub use transcript::FileUpdate; pub use transcript::ItemDelta; pub use transcript::PatchKind; diff --git a/codex-rs/cloud-client/src/prewarm.rs b/codex-rs/cloud-client/src/prewarm.rs index 4cdc9f8bfc..4597eb9e38 100644 --- a/codex-rs/cloud-client/src/prewarm.rs +++ b/codex-rs/cloud-client/src/prewarm.rs @@ -6,6 +6,22 @@ use bytes::Bytes; use bytes::BytesMut; use prost::Message; +/// A local project's GitHub origins to provision in the default cloud environment. +/// Callers must supply credential-free origin URLs; this client does not inspect local Git state. +#[derive(Clone)] +pub struct ProjectEnvironment { + pub name: String, + pub origin_urls: Vec, + /// Working directory inside the managed environment, when a project selects one. + pub cwd: Option, +} + +impl std::fmt::Debug for ProjectEnvironment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ProjectEnvironment([redacted])") + } +} + impl Client { /// Prewarm an ordinary coding thread in the default managed environment. /// The service makes it discoverable when it admits the first user message. @@ -17,6 +33,39 @@ impl Client { model: Option, model_provider: Option, ) -> Result { + self.prewarm_thread(model, model_provider, /*project*/ None) + .await + } + + /// Prewarm a coding thread with an optional project checkout. + pub async fn prewarm_thread( + &self, + model: Option, + model_provider: Option, + project: Option, + ) -> Result { + let managed = match project { + Some(ProjectEnvironment { + name, + origin_urls, + cwd, + }) => wire::ManagedEnvironment { + environment_config_id: Some("asenvcfg_base".to_owned()), + cwd, + overrides: Some(wire::EnvironmentOverrides { + repositories: Some(wire::RepositoryOverrides { + values: origin_urls + .into_iter() + .map(|origin_url| wire::RepositoryOverride { + origin_url: Some(origin_url), + }) + .collect(), + }), + name: Some(name), + }), + }, + None => wire::ManagedEnvironment::default(), + }; let response = self .raw_unary( wire::PrewarmRequest { @@ -24,7 +73,7 @@ impl Client { model_provider, environments: Some(wire::EnvironmentList { values: vec![wire::EnvironmentSelection { - managed: Some(wire::ManagedEnvironment {}), + managed: Some(managed), }], }), }, @@ -79,7 +128,31 @@ mod wire { pub managed: Option, } #[derive(Clone, Message)] - pub(super) struct ManagedEnvironment {} + pub(super) struct ManagedEnvironment { + #[prost(string, optional, tag = "1")] + pub environment_config_id: Option, + #[prost(string, optional, tag = "2")] + pub cwd: Option, + #[prost(message, optional, tag = "3")] + pub overrides: Option, + } + #[derive(Clone, Message)] + pub(super) struct EnvironmentOverrides { + #[prost(message, optional, tag = "1")] + pub repositories: Option, + #[prost(string, optional, tag = "2")] + pub name: Option, + } + #[derive(Clone, Message)] + pub(super) struct RepositoryOverrides { + #[prost(message, repeated, tag = "1")] + pub values: Vec, + } + #[derive(Clone, Message)] + pub(super) struct RepositoryOverride { + #[prost(string, optional, tag = "5")] + pub origin_url: Option, + } #[derive(Clone, Message)] pub(super) struct PrewarmResponse { #[prost(message, optional, tag = "1")] diff --git a/codex-rs/protocol/src/sanitized_git_url.rs b/codex-rs/protocol/src/sanitized_git_url.rs index daf6345d8b..c10408b86c 100644 --- a/codex-rs/protocol/src/sanitized_git_url.rs +++ b/codex-rs/protocol/src/sanitized_git_url.rs @@ -77,9 +77,13 @@ impl TryFrom<&str> for SanitizedGitUrl { // `file://` authority as its host and does not expose its userinfo. // Rebuilding only this part also preserves escaped repository paths. if let Some((scheme, authority_and_path)) = value.split_once("://") { - let authority_end = authority_and_path - .find('/') - .unwrap_or(authority_and_path.len()); + // Git treats '?' and '#' as authority text on non-HTTP transports. + let authority_end = if matches!(scheme, "http" | "https") { + authority_and_path.find(['/', '?', '#']) + } else { + authority_and_path.find('/') + } + .unwrap_or(authority_and_path.len()); let (authority, path) = authority_and_path.split_at(authority_end); let Some((_, host)) = authority.rsplit_once('@') else { return Ok(Self(original.to_owned())); diff --git a/codex-rs/protocol/src/sanitized_git_url_tests.rs b/codex-rs/protocol/src/sanitized_git_url_tests.rs index dc02f22d8a..48ac354135 100644 --- a/codex-rs/protocol/src/sanitized_git_url_tests.rs +++ b/codex-rs/protocol/src/sanitized_git_url_tests.rs @@ -15,6 +15,30 @@ fn removes_https_username_and_password() { assert_eq!(url.as_str(), "https://github.com/org/repo.git"); } +#[test] +fn http_query_and_fragment_are_outside_the_authority() { + for (scheme, suffix) in [("http", "?@github.com/a/b"), ("https", "#@github.com/a/b")] { + let expected = format!("{scheme}://example.com{suffix}"); + for authority in ["example.com", "user:password@example.com"] { + let remote = format!("{scheme}://{authority}{suffix}"); + let sanitized = SanitizedGitUrl::try_from(remote.as_str()).expect("parse remote URL"); + assert_eq!(sanitized.as_str(), expected); + } + } +} + +#[test] +fn non_http_credentials_can_contain_query_and_fragment_delimiters() { + for scheme in ["ssh", "git", "file"] { + let expected = format!("{scheme}://example.com/org/repo"); + for password in ["synthetic?value", "synthetic#value"] { + let remote = format!("{scheme}://user:{password}@example.com/org/repo"); + let sanitized = SanitizedGitUrl::try_from(remote.as_str()).expect("parse remote URL"); + assert_eq!(sanitized.as_str(), expected); + } + } +} + /// Tokens placed in the username field are credentials too. #[test] fn removes_https_username_without_password() { diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index c31484dc90..f2518c7d3e 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -856,6 +856,9 @@ impl App { self.select_agent_thread(tui, app_server, root_thread_id) .await?; } + if self.current_displayed_thread_id() == Some(root_thread_id) { + self.remember_attached_host_project(app_server); + } let read_only = self.chat_widget.is_external_writer_view(); if !read_only { self.replay_agents_overview_requests(app_server, root_thread_id) diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 43535f8395..aa926e2dd8 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -167,6 +167,7 @@ impl App { /*session_start_source*/ None, remote_cwd.as_deref(), selected_profile.as_ref(), + self.backend_preference_context.local_cwd.as_deref(), )), ) .await; diff --git a/codex-rs/tui/src/app/backend_selection.rs b/codex-rs/tui/src/app/backend_selection.rs index e1f5a8f5aa..7fe67dcf4b 100644 --- a/codex-rs/tui/src/app/backend_selection.rs +++ b/codex-rs/tui/src/app/backend_selection.rs @@ -76,6 +76,18 @@ impl BackendPreferenceContext { } impl App { + pub(super) fn remember_attached_host_project(&mut self, app_server: &AppServerSession) { + if !app_server.uses_remote_workspace() + && !crate::uses_remote_workspace_or_environment( + &self.app_server_target, + &self.environment_manager, + ) + { + self.backend_preference_context.local_cwd = + Some(self.chat_widget.config_ref().cwd.to_path_buf()); + } + } + pub(super) async fn new_thread_host_preference(&self) -> Option<&str> { let defaults = &self.local_settings.tui.default_hosts; if defaults.is_empty() { @@ -134,6 +146,9 @@ impl App { .get(&owner) .cloned() .ok_or("Connection settings are unavailable.")?; + if settings.target == AppServerTarget::Cloud && self.chat_widget.thread_id().is_some() { + self.remember_attached_host_project(backends.session_at(outgoing_slot)); + } let preference = if matches!(session, NewBackendSession::CarryDraft) && let Some(key) = backends.preference_key(slot) { @@ -191,6 +206,7 @@ impl App { /*session_start_source*/ None, /*remote_cwd_override*/ None, /*selected_profile*/ None, + self.backend_preference_context.local_cwd.as_deref(), ) .await .map_err(|error| format!("Failed to start the new conversation: {error}"))?; diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 5cece94d1d..7f71bd69d0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1163,6 +1163,7 @@ impl App { session_start_source, /*remote_cwd_override*/ None, /*selected_profile*/ None, + self.backend_preference_context.local_cwd.as_deref(), ) .await { @@ -1609,6 +1610,7 @@ impl App { .await { Ok(()) => { + self.remember_attached_host_project(app_server); if let Some(mut input) = retained_input { // A successful resume is the authority for clearing the old rejection. input.provider_error = None; diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 434fe9027e..6abd6b8800 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1170,6 +1170,7 @@ See the Codex keymap documentation for supported actions and examples." return shutdown_on_startup_error(app_server, &mut other_backends, err).await; } } + app.remember_attached_host_project(&app_server); if let Some(error) = provider_error { app.chat_widget .show_provider_error(error, /*rejected_message_id*/ None); diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index 9123904c51..f0496740c5 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -358,12 +358,21 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re } else { expected_model.to_string() }; + app.backend_preference_context.local_cwd = Some(launch.path().to_path_buf()); app.new_agents_overview_session( &mut tui, &mut server, explicit_cwd.then(|| destination.path().to_path_buf().abs()), ) .await?; + assert_eq!( + app.backend_preference_context.local_cwd.as_deref(), + Some(if !mode.starts_with("remote") && explicit_cwd { + destination.path() + } else { + launch.path() + }), + ); let cwd = match expected_cwd { "launch" => launch.path().display().to_string(), "destination" => destination.path().display().to_string(), @@ -733,6 +742,7 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< /*session_start_source*/ None, /*remote_cwd_override*/ None, Some(&selection), + /*host_project_cwd*/ None, ) .await?; app.enqueue_primary_thread_session(started.session, started.turns) @@ -814,9 +824,14 @@ async fn command_center_new_preserves_only_selected_server_profiles() -> Result< ServerNotification::ThreadSettingsUpdated(settings), ) .await?; + app.backend_preference_context.local_cwd = Some(home.path().to_owned()); for target in [other, thread_id] { app.select_agents_overview_thread(&mut tui, &mut server, target) .await?; + assert_eq!( + app.backend_preference_context.local_cwd.as_deref(), + Some(home.path()), + ); assert_eq!( app.chat_widget.thread_id(), Some(target), @@ -1284,6 +1299,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br .await?; let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); + let host_project = app.backend_preference_context.local_cwd.clone(); app.start_agents_overview_session( &mut tui, &mut failed_server, @@ -1293,6 +1309,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br ) .await?; assert_eq!(app.chat_widget.thread_id(), Some(second)); + assert_eq!(app.backend_preference_context.local_cwd, host_project); assert_eq!(manager.owner(&unused.root).unwrap(), None); assert!(unused.root.exists()); let rendered = crate::chatwidget::tests::helpers::render_bottom_popup( diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 0bd84cbe27..4241f9814e 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -880,6 +880,7 @@ async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf()); requests.lock().expect("request recorder lock").clear(); let mut tui = crate::tui::test_support::make_test_tui()?; + app.backend_preference_context.local_cwd = None; app.resume_target_session( &mut tui, &mut app_server, @@ -892,6 +893,10 @@ async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> }, ) .await?; + assert_eq!( + app.backend_preference_context.local_cwd.as_deref(), + Some(app.chat_widget.config_ref().cwd.as_path()), + ); assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); assert!(!app.chat_widget.is_external_writer_view()); diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 6cdeab2f3e..7a3a7966fb 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -466,6 +466,7 @@ impl App { /*session_start_source*/ None, /*remote_cwd_override*/ None, selected_profile.as_ref(), + /*host_project_cwd*/ None, ) .await }; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index d87374c570..3c3648db0d 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -5,6 +5,7 @@ mod account; pub(crate) use account::SessionAccount; +mod cloud_project; mod daybreak_api_key_compat; mod events; mod external_agent_config; @@ -1012,6 +1013,7 @@ impl AppServerSession { /*session_start_source*/ None, /*remote_cwd_override*/ None, /*selected_profile*/ None, + /*host_project_cwd*/ None, ) .await } @@ -1023,6 +1025,7 @@ impl AppServerSession { session_start_source: Option, remote_cwd_override: Option<&std::path::Path>, selected_profile: Option<&PermissionProfileSelection>, + host_project_cwd: Option<&std::path::Path>, ) -> Result { if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Server && selected_profile.is_some() @@ -1063,12 +1066,27 @@ impl AppServerSession { self.thread_tool_transport().configure(&mut params); } let request_handle = self.request_handle(); - let (response, history_support, task_tools_available) = - request_thread_start_with_history_fallback(&request_handle, request_id, params) + let project = if self.thread_params_mode == ThreadParamsMode::Cloud { + match host_project_cwd { + Some(cwd) => cloud_project::from_local_cwd(cwd).await, + None => None, + } + } else { + None + }; + let started = match project { + Some(project) => request_handle + .start_cloud_project_thread(request_id, params, project) .await - .map_err(|err| { - bootstrap_request_error("thread/start failed during TUI bootstrap", err) - })?; + .map(|response| (response, ThreadHistorySupport::Paginated, false)), + None => { + request_thread_start_with_history_fallback(&request_handle, request_id, params) + .await + } + }; + let (response, history_support, task_tools_available) = started.map_err(|err| { + bootstrap_request_error("thread/start failed during TUI bootstrap", err) + })?; if history_support == ThreadHistorySupport::LegacyOnly { self.history_support = ThreadHistorySupport::LegacyOnly; } diff --git a/codex-rs/tui/src/app_server_session/cloud_project.rs b/codex-rs/tui/src/app_server_session/cloud_project.rs new file mode 100644 index 0000000000..35cf7df759 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/cloud_project.rs @@ -0,0 +1,48 @@ +//! Resolves a host project into credential-free GitHub checkout metadata. +//! Local paths never become cloud executor cwd values or public wire fields. + +use codex_app_server_client::ProjectEnvironment; +use std::path::Path; + +pub(super) async fn from_local_cwd(cwd: &Path) -> Option { + let origin = codex_git_utils::get_git_origin_url(cwd).await?; + github_project(origin.as_str()) +} + +fn github_project(origin: &str) -> Option { + // A relative local path such as github.com/org/repo is not a network origin. + if !origin.contains("://") + && !origin.split_once(':').is_some_and(|(host, _)| { + host.rsplit('@') + .next() + .is_some_and(|host| host.eq_ignore_ascii_case("github.com")) + }) + { + return None; + } + let mut canonical = codex_git_utils::canonicalize_git_remote_url(origin)?; + canonical.make_ascii_lowercase(); + let (host, path) = canonical.split_once('/')?; + if !(host == "github.com" || (origin.starts_with("ssh://") && host == "ssh.github.com:443")) { + return None; + } + let (owner, repository) = path.split_once('/')?; + if ![owner, repository].iter().all(|part| { + !part.is_empty() + && !matches!(*part, "." | "..") + && part.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + }) { + return None; + } + Some(ProjectEnvironment { + name: repository.to_owned(), + cwd: Some(format!("/workspace/{owner}/{repository}")), + origin_urls: vec![format!("https://github.com/{owner}/{repository}")], + }) +} + +#[cfg(test)] +#[path = "cloud_project_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/cloud_project_tests.rs b/codex-rs/tui/src/app_server_session/cloud_project_tests.rs new file mode 100644 index 0000000000..317a122b5b --- /dev/null +++ b/codex-rs/tui/src/app_server_session/cloud_project_tests.rs @@ -0,0 +1,153 @@ +#![cfg(test)] + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn github_origins_are_normalized_without_credentials_or_query_strings() { + for origin in [ + "git@github.com:Org/Repo.git", + "ssh://git@github.com/Org/Repo.git", + "https://user:password@github.com/Org/Repo.git?token=secret#fragment", + ] { + let project = github_project(origin).unwrap(); + assert_eq!( + (project.name, project.origin_urls), + ( + "repo".to_owned(), + vec!["https://github.com/org/repo".to_owned()] + ), + ); + } +} + +#[tokio::test] +async fn cloud_starts_use_each_requests_host_project_without_caching() { + use super::super::AppServerSession; + use super::super::ThreadParamsMode; + use crate::legacy_core::config::ConfigBuilder; + use crate::local_settings::LocalSettings; + use codex_app_server_client::AppServerClient; + use codex_app_server_client::CloudClient; + use codex_app_server_client::CloudOperation; + use codex_app_server_protocol::ClientRequest; + use codex_app_server_protocol::JSONRPCErrorError; + + let home = tempfile::tempdir().unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await + .unwrap(); + let local_settings = LocalSettings::from(&config); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let misleading = tempfile::tempdir().unwrap(); + for (directory, origin) in [ + (first.path(), "git@github.com:org/first.git"), + (second.path(), "ssh://git@ssh.github.com:443/Org/Second.git"), + ( + misleading.path(), + "https://example.com?target=@github.com/org/wrong", + ), + ] { + for args in [ + vec!["init", "--quiet"], + vec!["remote", "add", "origin", origin], + ] { + assert!( + tokio::process::Command::new("git") + .args(args) + .current_dir(directory) + .status() + .await + .unwrap() + .success() + ); + } + } + let nested = first.path().join("src"); + std::fs::create_dir(&nested).unwrap(); + let projectless = tempfile::tempdir().unwrap(); + let (client, mut commands, _events) = CloudClient::channel(/*capacity*/ 1); + let mut session = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + let worker = tokio::spawn(async move { + for expected in [Some("first"), Some("second"), None, None, None, None] { + let command = commands.recv().await.unwrap(); + match (expected, command.operation) { + ( + Some(expected), + CloudOperation::StartProjectThread { + params, project, .. + }, + ) => { + assert_eq!(project.name, expected); + assert_eq!(project.cwd, Some(format!("/workspace/org/{expected}"))); + assert_eq!(params.cwd, None); + assert_eq!(params.runtime_workspace_roots, None); + assert_eq!( + project.origin_urls, + vec![format!("https://github.com/org/{expected}")] + ); + } + (None, CloudOperation::Request(request)) => { + assert!(matches!(*request, ClientRequest::ThreadStart { .. })) + } + _ => panic!("incorrect project routing"), + } + command + .response + .send(Ok(Err(JSONRPCErrorError { + code: -32000, + message: "checkout failed".into(), + data: None, + }))) + .unwrap(); + } + }); + for (mode, host) in [ + (ThreadParamsMode::Cloud, Some(nested.as_path())), + (ThreadParamsMode::Cloud, Some(second.path())), + (ThreadParamsMode::Cloud, Some(misleading.path())), + (ThreadParamsMode::Cloud, Some(projectless.path())), + (ThreadParamsMode::Cloud, None), + (ThreadParamsMode::Remote, Some(first.path())), + ] { + session.thread_params_mode = mode; + let result = session + .start_thread_with_session_start_source( + &local_settings, + &config, + /*session_start_source*/ None, + /*remote_cwd_override*/ None, + /*selected_profile*/ None, + host, + ) + .await; + assert!(result.unwrap_err().to_string().contains("checkout failed")); + } + worker.await.unwrap(); +} + +#[test] +fn unsupported_or_non_repository_origins_leave_the_thread_blank() { + for origin in [ + "/local/repository", + "github.com/org/repo", + "https://git.example.com/org/repo", + "http://ssh.github.com:443/org/repo.git", + "ssh://git@ssh.github.com:8443/org/repo.git", + "https://github.com/org", + "https://github.com/org/repo/tree/main", + "https://github.com/org/repo%2fother", + ] { + assert!( + github_project(origin).is_none(), + "unexpected origin: {origin}" + ); + } +} diff --git a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs index 6743f85a16..fb0b0d7d5a 100644 --- a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs @@ -60,6 +60,7 @@ async fn lifecycle_metadata_uses_local_prompt_history() -> Result<()> { /*session_start_source*/ None, /*remote_cwd_override*/ None, /*selected_profile*/ None, + /*host_project_cwd*/ None, ) .await?; assert_eq!(started.session.message_history, expected);