Start cloud threads with the local GitHub project (#60007)

## What changed

Pass the local project's GitHub origin to cloud prewarming so new cloud threads can provision a repository checkout. Preserve the attached local project when switching backends and resolve its origin for each thread start.

Normalize supported GitHub origins to credential-free HTTPS URLs and select a repository-specific cloud working directory. Unsupported origins retain the blank environment behavior. Keep project metadata outside the app-server wire protocol, and fix HTTP Git URL parsing so query strings and fragments cannot be mistaken for authority text.

## Testing

Add coverage for project routing, origin normalization and rejection, managed environment encoding, local project retention, and Git URL credential stripping.

GitOrigin-RevId: 5859769965faf060461ccc1cbb3fd48f8e2f2155
This commit is contained in:
Benjamin Carlsson
2026-11-06 23:00:38 +00:00
committed by copyberry
parent df769889c0
commit 7de8a0afc0
21 changed files with 491 additions and 15 deletions
+20
View File
@@ -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<ClientRequest>),
/// Host project metadata stays on the private transport, outside the app-server wire.
StartProjectThread {
request_id: RequestId,
params: Box<codex_app_server_protocol::ThreadStartParams>,
project: codex_cloud_client::ProjectEnvironment,
},
SubmitUserMessage(Box<TurnStartParams>),
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<RequestResult> {
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(
@@ -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<cloud::ProjectEnvironment>,
requests: &mut requests::Requests,
replies: &mut Replies,
output: &mpsc::UnboundedSender<AppServerEvent>,
@@ -256,7 +272,7 @@ async fn handle_request(
replies.admit_turn(&params.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),
@@ -54,6 +54,7 @@ impl Requests {
&mut self,
request: protocol::ClientRequest,
mode: cloud::SubmissionMode,
project: Option<cloud::ProjectEnvironment>,
output: &mpsc::UnboundedSender<crate::AppServerEvent>,
) -> Result<Response> {
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, .. } => {
+22
View File
@@ -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<codex_app_server_protocol::ThreadStartResponse, TypedRequestError> {
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,