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
+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")]