1use std::time::Duration;
15
16use agent_core::auth::{BrokerError, CredentialBroker, ProxyMethod, ProxyRequest, ProxyResponse};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize)]
22pub struct ClientMetadata {
23 #[serde(rename = "ideType")]
24 pub ide_type: &'static str,
25 pub platform: &'static str,
26 #[serde(rename = "pluginType")]
27 pub plugin_type: &'static str,
28 #[serde(rename = "duetProject", skip_serializing_if = "Option::is_none")]
29 pub duet_project: Option<String>,
30}
31
32impl ClientMetadata {
33 pub fn baseline(duet_project: Option<String>) -> Self {
34 Self {
35 ide_type: "IDE_UNSPECIFIED",
36 platform: "PLATFORM_UNSPECIFIED",
37 plugin_type: "GEMINI",
38 duet_project,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct LoadCodeAssistRequest {
45 #[serde(
46 rename = "cloudaicompanionProject",
47 skip_serializing_if = "Option::is_none"
48 )]
49 pub cloudaicompanion_project: Option<String>,
50 pub metadata: ClientMetadata,
51}
52
53#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
54pub struct TierMetadata {
55 pub id: Option<String>,
56 pub name: Option<String>,
57 #[serde(rename = "hasOnboardedPreviously", default)]
58 pub has_onboarded_previously: Option<bool>,
59 #[serde(rename = "isDefault", default)]
60 pub is_default: Option<bool>,
61}
62
63#[derive(Debug, Clone, Deserialize, Default)]
64pub struct IneligibleTier {
65 #[serde(rename = "tierId")]
66 pub tier_id: Option<String>,
67 #[serde(rename = "reasonCode")]
68 pub reason_code: Option<String>,
69 #[serde(rename = "reasonMessage")]
70 pub reason_message: Option<String>,
71}
72
73#[derive(Debug, Clone, Deserialize, Default)]
74pub struct LoadCodeAssistResponse {
75 #[serde(rename = "cloudaicompanionProject", default)]
76 pub cloudaicompanion_project: Option<String>,
77 #[serde(rename = "currentTier", default)]
78 pub current_tier: Option<TierMetadata>,
79 #[serde(rename = "allowedTiers", default)]
80 pub allowed_tiers: Vec<TierMetadata>,
81 #[serde(rename = "ineligibleTiers", default)]
82 pub ineligible_tiers: Vec<IneligibleTier>,
83}
84
85#[derive(Debug, Clone, Serialize)]
86pub struct OnboardUserRequest {
87 #[serde(rename = "tierId")]
88 pub tier_id: String,
89 #[serde(
90 rename = "cloudaicompanionProject",
91 skip_serializing_if = "Option::is_none"
92 )]
93 pub cloudaicompanion_project: Option<String>,
94 pub metadata: ClientMetadata,
95}
96
97#[derive(Debug, Clone, Deserialize, Default)]
98pub struct OperationResponse {
99 #[serde(default)]
100 pub done: bool,
101 #[serde(default)]
102 pub name: Option<String>,
103 #[serde(default)]
104 pub response: Option<OperationDone>,
105 #[serde(default)]
106 pub error: Option<OperationError>,
107}
108
109#[derive(Debug, Clone, Deserialize, Default)]
110pub struct OperationDone {
111 #[serde(rename = "cloudaicompanionProject", default)]
112 pub cloudaicompanion_project: Option<CloudCompanionProject>,
113}
114
115#[derive(Debug, Clone, Deserialize, Default)]
116pub struct CloudCompanionProject {
117 pub id: Option<String>,
118}
119
120#[derive(Debug, Clone, Deserialize, Default)]
121pub struct OperationError {
122 pub code: Option<i32>,
123 pub message: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct UserData {
130 pub project_id: String,
131 pub tier_id: String,
132 pub tier_name: Option<String>,
133 pub has_onboarded_previously: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum SetupError {
138 ProjectIdRequired,
140 IneligibleTiers(Vec<String>),
142 OperationFailed(String),
144 OnboardingTimeout,
146 ValidationRequired {
148 link: Option<String>,
149 description: Option<String>,
150 },
151 Broker(String),
153 InvalidResponse,
155 InvalidNumericProjectId,
157}
158
159impl std::fmt::Display for SetupError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 Self::ProjectIdRequired => f.write_str(
163 "google-gemini: no Cloud project id available. Set GOOGLE_CLOUD_PROJECT.",
164 ),
165 Self::IneligibleTiers(reasons) => {
166 write!(
167 f,
168 "google-gemini: ineligible for available tiers: {}",
169 reasons.join("; ")
170 )
171 }
172 Self::OperationFailed(msg) => {
173 write!(f, "google-gemini: onboarding operation failed: {msg}")
174 }
175 Self::OnboardingTimeout => f.write_str("google-gemini: onboarding timed out"),
176 Self::ValidationRequired { link, .. } => match link {
177 Some(link) => write!(
178 f,
179 "google-gemini: account validation required. Visit: {link}"
180 ),
181 None => f.write_str("google-gemini: account validation required"),
182 },
183 Self::Broker(msg) => write!(f, "google-gemini: broker error: {msg}"),
184 Self::InvalidResponse => f.write_str("google-gemini: invalid Code Assist response"),
185 Self::InvalidNumericProjectId => {
186 f.write_str("google-gemini: numeric project ids are not supported")
187 }
188 }
189 }
190}
191
192impl From<BrokerError> for SetupError {
193 fn from(err: BrokerError) -> Self {
194 SetupError::Broker(err.to_string())
195 }
196}
197
198pub const ONBOARDING_MAX_ATTEMPTS: usize = 60; pub const ONBOARDING_POLL_INTERVAL: Duration = Duration::from_secs(5);
204
205pub async fn setup_user<B: CredentialBroker + ?Sized>(
213 broker: &B,
214 project_id_env: Option<String>,
215) -> Result<UserData, SetupError> {
216 setup_user_with_sleeper(broker, project_id_env, tokio_sleeper).await
217}
218
219pub async fn setup_user_with_sleeper<B, F, Fut>(
222 broker: &B,
223 project_id_env: Option<String>,
224 sleeper: F,
225) -> Result<UserData, SetupError>
226where
227 B: CredentialBroker + ?Sized,
228 F: Fn(Duration) -> Fut,
229 Fut: std::future::Future<Output = ()>,
230{
231 if let Some(p) = &project_id_env {
232 if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) {
233 return Err(SetupError::InvalidNumericProjectId);
234 }
235 }
236 let load = load_code_assist(broker, project_id_env.clone()).await?;
237
238 if let Some(current) = &load.current_tier {
239 if let Some(project_id) = load
240 .cloudaicompanion_project
241 .clone()
242 .or(project_id_env.clone())
243 {
244 return Ok(UserData {
245 project_id,
246 tier_id: current.id.clone().unwrap_or_else(|| "STANDARD".into()),
247 tier_name: current.name.clone(),
248 has_onboarded_previously: current.has_onboarded_previously.unwrap_or(true),
249 });
250 }
251 return Err(ineligibility_or_project_id(&load));
253 }
254
255 let tier = default_allowed_tier(&load).ok_or_else(|| ineligibility_or_project_id(&load))?;
257 let tier_id = tier.id.clone().unwrap_or_else(|| "STANDARD".into());
258 let onboard_project = if tier_id == "FREE" {
260 None
261 } else {
262 project_id_env.clone()
263 };
264 let onboard_req = OnboardUserRequest {
265 tier_id: tier_id.clone(),
266 cloudaicompanion_project: onboard_project.clone(),
267 metadata: ClientMetadata::baseline(onboard_project.clone()),
268 };
269 let mut op = onboard_user(broker, &onboard_req).await?;
270 if !op.done {
271 let name = op
272 .name
273 .clone()
274 .ok_or_else(|| SetupError::OperationFailed("missing operation name".into()))?;
275 for _ in 0..ONBOARDING_MAX_ATTEMPTS {
276 if op.done {
277 break;
278 }
279 sleeper(ONBOARDING_POLL_INTERVAL).await;
280 op = get_operation(broker, &name).await?;
281 }
282 if !op.done {
283 return Err(SetupError::OnboardingTimeout);
284 }
285 }
286 if let Some(err) = op.error {
287 return Err(SetupError::OperationFailed(
288 err.message.unwrap_or_else(|| "unspecified".into()),
289 ));
290 }
291 let project_id = op
292 .response
293 .as_ref()
294 .and_then(|r| r.cloudaicompanion_project.as_ref())
295 .and_then(|p| p.id.clone())
296 .or(project_id_env)
297 .ok_or(SetupError::ProjectIdRequired)?;
298 Ok(UserData {
299 project_id,
300 tier_id,
301 tier_name: tier.name.clone(),
302 has_onboarded_previously: tier.has_onboarded_previously.unwrap_or(false),
303 })
304}
305
306async fn tokio_sleeper(d: Duration) {
307 tokio::time::sleep(d).await;
308}
309
310fn default_allowed_tier(res: &LoadCodeAssistResponse) -> Option<&TierMetadata> {
311 res.allowed_tiers
312 .iter()
313 .find(|t| t.is_default == Some(true))
314}
315
316fn ineligibility_or_project_id(res: &LoadCodeAssistResponse) -> SetupError {
317 if res.ineligible_tiers.is_empty() {
318 SetupError::ProjectIdRequired
319 } else {
320 SetupError::IneligibleTiers(
321 res.ineligible_tiers
322 .iter()
323 .filter_map(|t| t.reason_message.clone())
324 .collect(),
325 )
326 }
327}
328
329pub async fn load_code_assist<B: CredentialBroker + ?Sized>(
332 broker: &B,
333 project_id: Option<String>,
334) -> Result<LoadCodeAssistResponse, SetupError> {
335 let body = LoadCodeAssistRequest {
336 cloudaicompanion_project: project_id.clone(),
337 metadata: ClientMetadata::baseline(project_id),
338 };
339 let resp = broker
340 .proxy(ProxyRequest {
341 provider: "google-gemini".into(),
342 method: ProxyMethod::Post,
343 path: "/v1internal:loadCodeAssist".into(),
344 body: Some(serde_json::to_value(&body).map_err(|_| SetupError::InvalidResponse)?),
345 stream: false,
346 body_bytes: None,
347 })
348 .await?;
349 parse_success::<LoadCodeAssistResponse>(resp)
350}
351
352pub async fn onboard_user<B: CredentialBroker + ?Sized>(
353 broker: &B,
354 req: &OnboardUserRequest,
355) -> Result<OperationResponse, SetupError> {
356 let resp = broker
357 .proxy(ProxyRequest {
358 provider: "google-gemini".into(),
359 method: ProxyMethod::Post,
360 path: "/v1internal:onboardUser".into(),
361 body: Some(serde_json::to_value(req).map_err(|_| SetupError::InvalidResponse)?),
362 stream: false,
363 body_bytes: None,
364 })
365 .await?;
366 parse_success::<OperationResponse>(resp)
367}
368
369pub async fn get_operation<B: CredentialBroker + ?Sized>(
370 broker: &B,
371 operation_name: &str,
372) -> Result<OperationResponse, SetupError> {
373 if !is_safe_operation_name(operation_name) {
384 return Err(SetupError::InvalidResponse);
385 }
386 let path = format!("/v1internal/{operation_name}");
387 let resp = broker
388 .proxy(ProxyRequest {
389 provider: "google-gemini".into(),
390 method: ProxyMethod::Get,
391 path,
392 body: None,
393 stream: false,
394 body_bytes: None,
395 })
396 .await?;
397 parse_success::<OperationResponse>(resp)
398}
399
400fn is_safe_operation_name(name: &str) -> bool {
404 let Some(rest) = name.strip_prefix("operations/") else {
405 return false;
406 };
407 if rest.is_empty() {
408 return false;
409 }
410 for segment in rest.split('/') {
411 if segment.is_empty() || segment == "." || segment == ".." {
412 return false;
413 }
414 if segment
415 .chars()
416 .any(|c| matches!(c, '?' | '#' | '\\' | ' ' | '\t' | '\n' | '\r'))
417 {
418 return false;
419 }
420 }
421 true
422}
423
424fn parse_success<T: for<'de> Deserialize<'de>>(resp: ProxyResponse) -> Result<T, SetupError> {
425 if !(200..300).contains(&resp.status) {
426 return Err(SetupError::OperationFailed(format!(
427 "HTTP {} from cloudcode-pa",
428 resp.status
429 )));
430 }
431 serde_json::from_str::<T>(&resp.body).map_err(|_| SetupError::InvalidResponse)
432}
433
434#[cfg(test)]
437mod tests {
438 use super::*;
439 use agent_core::auth::{AccessToken, ProxyByteStream};
440 use async_trait::async_trait;
441 use std::sync::{Arc, Mutex};
442
443 struct FakeBroker {
444 seen: Arc<Mutex<Vec<ProxyRequest>>>,
445 responses: Mutex<Vec<Result<ProxyResponse, BrokerError>>>,
446 }
447
448 impl FakeBroker {
449 fn new(responses: Vec<Result<ProxyResponse, BrokerError>>) -> Self {
450 Self {
451 seen: Arc::default(),
452 responses: Mutex::new(responses),
453 }
454 }
455 }
456
457 #[async_trait]
458 impl CredentialBroker for FakeBroker {
459 async fn access_token(
460 &self,
461 _p: agent_core::auth::OAuthProviderId,
462 ) -> Result<AccessToken, BrokerError> {
463 Err(BrokerError::NotConfigured("test".into()))
464 }
465 async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
466 self.seen.lock().unwrap().push(request);
467 self.responses.lock().unwrap().remove(0)
468 }
469 async fn proxy_stream(
470 &self,
471 _request: ProxyRequest,
472 ) -> Result<ProxyByteStream, BrokerError> {
473 Err(BrokerError::Denied("not implemented in fake".into()))
474 }
475 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
476 Err(BrokerError::Denied("not implemented".into()))
477 }
478 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
479 Ok(vec![])
480 }
481 }
482
483 fn ok(body: &str) -> Result<ProxyResponse, BrokerError> {
484 Ok(ProxyResponse {
485 status: 200,
486 body: body.to_string(),
487 })
488 }
489
490 async fn no_sleep(_: Duration) {}
491
492 #[tokio::test]
493 async fn setup_returns_existing_project_when_current_tier_present() {
494 let broker = FakeBroker::new(vec![ok(r#"{"cloudaicompanionProject":"my-proj",
495 "currentTier":{"id":"STANDARD","name":"Standard","hasOnboardedPreviously":true}}"#)]);
496 let data = setup_user_with_sleeper(&broker, None, no_sleep)
497 .await
498 .unwrap();
499 assert_eq!(data.project_id, "my-proj");
500 assert_eq!(data.tier_id, "STANDARD");
501 assert!(data.has_onboarded_previously);
502
503 let seen = broker.seen.lock().unwrap();
505 assert_eq!(seen[0].provider, "google-gemini");
506 assert_eq!(seen[0].path, "/v1internal:loadCodeAssist");
507 assert_eq!(seen[0].method, ProxyMethod::Post);
508 let body = seen[0].body.as_ref().unwrap();
509 assert_eq!(body["metadata"]["ideType"], "IDE_UNSPECIFIED");
510 assert_eq!(body["metadata"]["pluginType"], "GEMINI");
511 }
512
513 #[tokio::test]
514 async fn setup_uses_env_project_when_response_omits_it() {
515 let broker = FakeBroker::new(vec![ok(
516 r#"{"currentTier":{"id":"STANDARD","name":"Standard"}}"#,
517 )]);
518 let data = setup_user_with_sleeper(&broker, Some("env-proj".into()), no_sleep)
519 .await
520 .unwrap();
521 assert_eq!(data.project_id, "env-proj");
522 assert_eq!(data.tier_id, "STANDARD");
523 }
524
525 #[tokio::test]
526 async fn setup_reports_ineligibility_when_no_project_and_no_current_tier() {
527 let broker = FakeBroker::new(vec![ok(
528 r#"{"ineligibleTiers":[{"tierId":"FREE","reasonMessage":"not available in your region"}]}"#,
529 )]);
530 match setup_user_with_sleeper(&broker, None, no_sleep).await {
531 Err(SetupError::IneligibleTiers(msgs)) => {
532 assert_eq!(msgs, vec!["not available in your region".to_string()]);
533 }
534 other => panic!("expected IneligibleTiers, got {other:?}"),
535 }
536 }
537
538 #[tokio::test]
539 async fn setup_onboards_free_tier_with_no_project_and_polls_operation() {
540 let broker = FakeBroker::new(vec![
541 ok(r#"{"allowedTiers":[{"id":"FREE","name":"Free","isDefault":true}]}"#),
543 ok(r#"{"done":false,"name":"operations/op-42"}"#),
545 ok(r#"{"done":false,"name":"operations/op-42"}"#),
547 ok(r#"{"done":true,"response":{"cloudaicompanionProject":{"id":"managed-proj"}}}"#),
549 ]);
550 let data = setup_user_with_sleeper(&broker, None, no_sleep)
551 .await
552 .unwrap();
553 assert_eq!(data.project_id, "managed-proj");
554 assert_eq!(data.tier_id, "FREE");
555
556 let seen = broker.seen.lock().unwrap();
557 assert_eq!(seen.len(), 4);
558 assert_eq!(seen[0].path, "/v1internal:loadCodeAssist");
559 assert_eq!(seen[1].path, "/v1internal:onboardUser");
560 let onboard_body = seen[1].body.as_ref().unwrap();
562 assert!(
563 onboard_body.get("cloudaicompanionProject").is_none()
564 || onboard_body["cloudaicompanionProject"].is_null()
565 );
566 assert_eq!(seen[2].path, "/v1internal/operations/op-42");
567 assert_eq!(seen[3].path, "/v1internal/operations/op-42");
568 }
569
570 #[tokio::test]
571 async fn setup_rejects_numeric_project_id() {
572 let broker = FakeBroker::new(vec![]);
573 assert_eq!(
574 setup_user_with_sleeper(&broker, Some("12345".into()), no_sleep).await,
575 Err(SetupError::InvalidNumericProjectId)
576 );
577 assert!(broker.seen.lock().unwrap().is_empty());
579 }
580
581 #[tokio::test]
582 async fn setup_surfaces_operation_error_secret_safely() {
583 let broker = FakeBroker::new(vec![
584 ok(r#"{"allowedTiers":[{"id":"STANDARD","name":"Std","isDefault":true}]}"#),
585 ok(r#"{"done":true,"error":{"code":13,"message":"internal"}}"#),
586 ]);
587 match setup_user_with_sleeper(&broker, Some("proj-x".into()), no_sleep).await {
588 Err(SetupError::OperationFailed(msg)) => assert!(msg.contains("internal")),
589 other => panic!("expected OperationFailed, got {other:?}"),
590 }
591 }
592
593 #[tokio::test]
594 async fn setup_bounded_polling_times_out_instead_of_spinning() {
595 let mut responses: Vec<Result<ProxyResponse, BrokerError>> = vec![
597 ok(r#"{"allowedTiers":[{"id":"STANDARD","name":"Std","isDefault":true}]}"#),
598 ok(r#"{"done":false,"name":"operations/op-slow"}"#),
599 ];
600 for _ in 0..ONBOARDING_MAX_ATTEMPTS + 5 {
601 responses.push(ok(r#"{"done":false,"name":"operations/op-slow"}"#));
602 }
603 let broker = FakeBroker::new(responses);
604 let err = setup_user_with_sleeper(&broker, Some("proj".into()), no_sleep)
605 .await
606 .unwrap_err();
607 assert_eq!(err, SetupError::OnboardingTimeout);
608 }
609
610 #[test]
611 fn get_operation_rejects_traversal_and_absolute_paths() {
612 let broker = FakeBroker::new(vec![]);
618 let rt = tokio::runtime::Builder::new_current_thread()
619 .enable_all()
620 .build()
621 .unwrap();
622 for bad in [
623 "", "../secret", "op-42", "operations/", "operations/../x", "operations/x/../y", "operations//x", "/operations/x", "operations/x?y=1", "operations/x#frag", ] {
634 let r = rt.block_on(get_operation(&broker, bad));
635 assert!(
636 matches!(r, Err(SetupError::InvalidResponse)),
637 "must reject {bad:?}"
638 );
639 }
640 assert!(broker.seen.lock().unwrap().is_empty());
642 }
643
644 #[tokio::test]
650 async fn get_operation_matches_reference_wire_contract() {
651 let broker = FakeBroker::new(vec![ok(r#"{"done":true}"#)]);
653 let _ = get_operation(&broker, "operations/op-42").await.unwrap();
654 let seen = broker.seen.lock().unwrap();
655 assert_eq!(seen.len(), 1);
656 assert_eq!(seen[0].provider, "google-gemini");
657 assert_eq!(
658 seen[0].method,
659 ProxyMethod::Get,
660 "reference uses GET for getOperation; POST returns 404 from cloudcode-pa"
661 );
662 assert_eq!(seen[0].path, "/v1internal/operations/op-42");
663 assert!(
664 seen[0].body.is_none(),
665 "GET must not carry a body; reference sends none"
666 );
667 drop(seen);
668
669 let broker = FakeBroker::new(vec![ok(r#"{"done":true}"#)]);
671 let _ = get_operation(&broker, "operations/projects/my-proj/op-xyz")
672 .await
673 .unwrap();
674 let seen = broker.seen.lock().unwrap();
675 assert_eq!(
676 seen[0].path,
677 "/v1internal/operations/projects/my-proj/op-xyz"
678 );
679 assert_eq!(seen[0].method, ProxyMethod::Get);
680 }
681}