Skip to main content

agent_engine/runtime/google_gemini/
setup.rs

1//! Google Gemini Code Assist project setup: bounded `loadCodeAssist` and
2//! `onboardUser`/operations polling via the broker proxy.
3//!
4//! Experimental. `cloudcode-pa.googleapis.com/v1internal` is a
5//! product-client-observed integration surface and is not documented as a
6//! stable public third-party API; see docs/specs/google-gemini-oauth-spec.md.
7//!
8//! This module never touches secrets or the network directly — it composes
9//! typed `ProxyRequest` values through a `CredentialBroker`, so all upstream
10//! egress goes through the broker's pinned host + path allowlist + timeouts +
11//! body caps. Validation links returned by `loadCodeAssist` are surfaced to
12//! the caller but never automatically followed.
13
14use std::time::Duration;
15
16use agent_core::auth::{BrokerError, CredentialBroker, ProxyMethod, ProxyRequest, ProxyResponse};
17use serde::{Deserialize, Serialize};
18
19// ── Wire types (subset needed for setup) ─────────────────────────────────────
20
21#[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// ── Public: user-facing setup result ─────────────────────────────────────────
127
128#[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    /// The user must provide a Google Cloud project id (non-free tier).
139    ProjectIdRequired,
140    /// One or more tiers are ineligible; message list is safe to display.
141    IneligibleTiers(Vec<String>),
142    /// Onboarding LRO returned an error status; message is upstream-owned.
143    OperationFailed(String),
144    /// Onboarding polling exhausted the bounded budget.
145    OnboardingTimeout,
146    /// Upstream returned a validation link that requires user action.
147    ValidationRequired {
148        link: Option<String>,
149        description: Option<String>,
150    },
151    /// Broker/transport error surfaced verbatim (already secret-safe).
152    Broker(String),
153    /// Response body was not valid JSON of the expected shape.
154    InvalidResponse,
155    /// Numeric project id is not accepted (matches reference client).
156    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
198// ── Bounded polling ──────────────────────────────────────────────────────────
199
200/// Bounded onboarding polling budget. The reference client polls every 5s
201/// indefinitely; we cap the total wait so tests and production never spin.
202pub const ONBOARDING_MAX_ATTEMPTS: usize = 60; // ~5 minutes at 5s spacing
203pub const ONBOARDING_POLL_INTERVAL: Duration = Duration::from_secs(5);
204
205// ── High-level setup ─────────────────────────────────────────────────────────
206
207/// Resolve the user's Code Assist project id and tier through the broker.
208///
209/// The `project_id_env` argument is what the caller reads from
210/// `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` — passing it in keeps
211/// this module env-free and testable.
212pub 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
219/// Same as `setup_user`, but the caller injects a sleeper (test seam). The
220/// polling loop is bounded by ONBOARDING_MAX_ATTEMPTS regardless.
221pub 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        // No project — surface ineligibility reasons if present.
252        return Err(ineligibility_or_project_id(&load));
253    }
254
255    // Onboarding path.
256    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    // Free tier onboarding must NOT include cloudaicompanionProject.
259    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
329// ── Typed broker POSTs ───────────────────────────────────────────────────────
330
331pub 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    // Match the reference client's `getOperationUrl(name) = ${base}/${name}`
374    // and its `getOperation` = HTTP GET. `name` is the full LRO resource name
375    // (e.g. `operations/op-42` or nested `operations/projects/{p}/op-xyz`)
376    // and must be embedded verbatim after the API version — stripping the
377    // `operations/` prefix or rewriting interior slashes yields HTTP 404
378    // from cloudcode-pa.
379    //
380    // We keep a defensive validator so a malformed/hostile name cannot be
381    // used to escape the pinned allowlist prefix `/v1internal/operations/`
382    // enforced by the broker.
383    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
400/// Accept only Google LRO names of the form `operations/<segment>[/<segment>…]`
401/// where each segment is non-empty, does not equal `..`, and contains no
402/// URL-reserved characters that could break the pinned host + path allowlist.
403fn 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// ── Tests ────────────────────────────────────────────────────────────────────
435
436#[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        // Wire request went to the pinned path with metadata baseline.
504        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            // loadCodeAssist: no currentTier, one allowedTier default FREE.
542            ok(r#"{"allowedTiers":[{"id":"FREE","name":"Free","isDefault":true}]}"#),
543            // onboardUser: LRO pending, name returned.
544            ok(r#"{"done":false,"name":"operations/op-42"}"#),
545            // getOperation poll #1: still pending.
546            ok(r#"{"done":false,"name":"operations/op-42"}"#),
547            // getOperation poll #2: done, returns project id.
548            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        // FREE tier must NOT carry cloudaicompanionProject in onboardUser.
561        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        // No wire call must have happened.
578        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        // Enough poll responses to trigger the bound; supply pending forever.
596        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        // The path builder must reject traversal/absolute-path tokens, but
613        // MUST accept valid Google LRO names which begin with `operations/`
614        // and may contain additional slash-separated segments (e.g.
615        // `projects/{p}/operations/{o}`), matching the reference client's
616        // `getOperationUrl(name)` = `${base}/${name}` construction.
617        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            "",                  // empty
624            "../secret",         // does not start with operations/
625            "op-42",             // missing operations/ prefix
626            "operations/",       // trailing empty segment
627            "operations/../x",   // traversal
628            "operations/x/../y", // traversal mid-name
629            "operations//x",     // empty segment
630            "/operations/x",     // absolute path
631            "operations/x?y=1",  // query character
632            "operations/x#frag", // fragment character
633        ] {
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        // No wire calls emitted for any bad name.
641        assert!(broker.seen.lock().unwrap().is_empty());
642    }
643
644    /// Regression: matches the reference client's `getOperation` contract.
645    /// The wire request MUST be an HTTP GET (not POST), MUST carry no body,
646    /// and MUST embed the full LRO `name` verbatim after the API version —
647    /// including any interior slashes for nested resource names. Failing any
648    /// of these produces `HTTP 404 from cloudcode-pa` in production.
649    #[tokio::test]
650    async fn get_operation_matches_reference_wire_contract() {
651        // Simple LRO name.
652        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        // Nested LRO name — must be preserved verbatim, not stripped.
670        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}