mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
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:
committed by
copyberry
parent
df769889c0
commit
7de8a0afc0
@@ -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(¶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),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Working directory inside the managed environment, when a project selects one.
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
model_provider: Option<String>,
|
||||
) -> Result<crate::ThreadActivation, Error> {
|
||||
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<String>,
|
||||
model_provider: Option<String>,
|
||||
project: Option<ProjectEnvironment>,
|
||||
) -> Result<crate::ThreadActivation, Error> {
|
||||
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<ManagedEnvironment>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct ManagedEnvironment {}
|
||||
pub(super) struct ManagedEnvironment {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub environment_config_id: Option<String>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub cwd: Option<String>,
|
||||
#[prost(message, optional, tag = "3")]
|
||||
pub overrides: Option<EnvironmentOverrides>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct EnvironmentOverrides {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub repositories: Option<RepositoryOverrides>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct RepositoryOverrides {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
pub values: Vec<RepositoryOverride>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct RepositoryOverride {
|
||||
#[prost(string, optional, tag = "5")]
|
||||
pub origin_url: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct PrewarmResponse {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -466,6 +466,7 @@ impl App {
|
||||
/*session_start_source*/ None,
|
||||
/*remote_cwd_override*/ None,
|
||||
selected_profile.as_ref(),
|
||||
/*host_project_cwd*/ None,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
@@ -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<ThreadStartSource>,
|
||||
remote_cwd_override: Option<&std::path::Path>,
|
||||
selected_profile: Option<&PermissionProfileSelection>,
|
||||
host_project_cwd: Option<&std::path::Path>,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ProjectEnvironment> {
|
||||
let origin = codex_git_utils::get_git_origin_url(cwd).await?;
|
||||
github_project(origin.as_str())
|
||||
}
|
||||
|
||||
fn github_project(origin: &str) -> Option<ProjectEnvironment> {
|
||||
// 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;
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user