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,
+49
View File
@@ -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.
+1
View File
@@ -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;
+75 -2
View File
@@ -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")]
+7 -3
View File
@@ -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() {
+3
View File
@@ -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;
+16
View File
@@ -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;
+1
View File
@@ -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
};
+23 -5
View File
@@ -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);