Skip to main content

agent_core/core/auth/
broker.rs

1//! The credential broker — the mandatory boundary between runtimes and
2//! provider secrets (Checkpoint 1 decision, `docs/decisions/credential-broker-checkpoint-1.md`).
3//!
4//! ## Invariants (enforced by construction and tests)
5//!
6//! * Runtime/TUI code talks to a [`CredentialBroker`] and never reads
7//!   `auth.json`, provider-key config, or credential environment variables.
8//!   All of that discovery lives inside [`LocalBroker`] — behind the boundary.
9//! * OAuth responses vend **access token + expiry only** ([`AccessToken`] has
10//!   no refresh field to put one in). Refresh tokens stay broker-owned.
11//! * Static API keys are never vended, locally or remotely. They are applied
12//!   broker-side via the typed request proxy ([`ProxyRequest`]): the broker
13//!   pins the destination URL from its own [`static_providers`] table and
14//!   attaches the bearer key itself.
15//! * Capability/status queries ([`ProviderStatus`]) expose configured-ness
16//!   only — never key material.
17//! * Fail closed: a missing credential is an error, never a fallback to a
18//!   direct read at the call site.
19//!
20//! [`static_providers`]: super::static_providers
21
22use std::collections::BTreeMap;
23use std::pin::Pin;
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use futures::Stream;
29use serde::{Deserialize, Serialize};
30
31use super::cloud::{CloudProviderId, InvokeRequest};
32use super::provider::OAuthProviderId;
33use super::static_providers::{
34    allowed_proxy_paths, static_provider, StaticProviderSpec, LOCAL_DEFAULT_BASE_URL,
35    LOCAL_PROVIDER_KEY, STATIC_PROVIDERS,
36};
37use super::{load_provider_auth, storage};
38
39// ── Buffering / time limits ──────────────────────────────────────────────────
40//
41// Streaming (SSE) bodies are never buffered — they flow chunk-by-chunk with
42// backpressure. These caps bound everything the broker (or a broker client)
43// must hold in memory, so a hostile or broken upstream cannot balloon the
44// process or smuggle unbounded content through error strings.
45
46/// Maximum serialized size of a proxy request body.
47pub const MAX_PROXY_REQUEST_BYTES: usize = 2 * 1024 * 1024;
48/// Maximum buffered (non-streaming) response body size.
49pub const MAX_PROXY_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
50/// Historical cap for upstream error-body snippets. Upstream/broker error
51/// bodies are no longer read at all (spec §5.1: they may echo the request);
52/// the constant remains only because it is re-exported from `auth::mod`.
53pub const MAX_UPSTREAM_ERROR_BYTES: usize = 2 * 1024;
54/// Total time budget for a buffered (non-streaming) broker-executed request.
55pub const PROXY_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
56
57/// Fixed Anthropic usage endpoint — a typed broker operation, not a general
58/// proxy target. Callers cannot vary the URL or path.
59const ANTHROPIC_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
60const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20";
61
62/// Pinned Code Assist host for the broker-only Google Gemini proxy runtime.
63/// The service is treated as experimental and only the exact `v1internal`
64/// method paths reviewed in the spec are permitted (setup + streaming).
65pub const GOOGLE_GEMINI_CODE_ASSIST_BASE_URL: &str = "https://cloudcode-pa.googleapis.com";
66
67/// Exhaustive allowlist of `cloudcode-pa` methods the broker may proxy.
68/// Deliberately narrow: setup uses loadCodeAssist + onboardUser + operations
69/// polling, runtime uses streamGenerateContent. Anything else is denied.
70pub(crate) fn is_allowed_google_gemini_path(path: &str) -> bool {
71    matches!(
72        path,
73        "/v1internal:loadCodeAssist"
74            | "/v1internal:onboardUser"
75            | "/v1internal:streamGenerateContent"
76            | "/v1internal:countTokens"
77    ) || path.starts_with("/v1internal/operations/")
78}
79
80/// Pinned ChatGPT backend host for OpenAI Codex catalog traffic.
81pub const OPENAI_CODEX_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api";
82
83/// Allow only the official Codex model-catalog path:
84/// `GET /models?client_version=<non-empty>`.
85///
86/// Query is constrained to a single non-empty `client_version` key — no extra
87/// params, no empty version, no bare `/models`. Same-host inference paths
88/// (`/codex/responses`, …) stay out of this catalog-only allowlist.
89pub(crate) fn is_allowed_openai_codex_path(path: &str) -> bool {
90    let Some((base, query)) = path.split_once('?') else {
91        return false;
92    };
93    if base != "/models" {
94        return false;
95    }
96    let mut client_version: Option<&str> = None;
97    for pair in query.split('&') {
98        let Some((k, v)) = pair.split_once('=') else {
99            return false;
100        };
101        if k != "client_version" || client_version.is_some() {
102            return false;
103        }
104        if v.trim().is_empty() {
105            return false;
106        }
107        client_version = Some(v);
108    }
109    client_version.is_some()
110}
111
112/// Allow only Anthropic's paginated models catalog path:
113/// `/v1/models` or `/v1/models?limit=…` with optional `after_id=…`.
114///
115/// No other same-host endpoints (messages, keys, admin, …) are permitted.
116pub(crate) fn is_allowed_anthropic_path(path: &str) -> bool {
117    if path == "/v1/models" {
118        return true;
119    }
120    let Some((base, query)) = path.split_once('?') else {
121        return false;
122    };
123    if base != "/v1/models" {
124        return false;
125    }
126    if query.is_empty() {
127        return false;
128    }
129    let mut saw_limit = false;
130    let mut saw_after_id = false;
131    for pair in query.split('&') {
132        let Some((k, v)) = pair.split_once('=') else {
133            return false;
134        };
135        if v.is_empty() {
136            return false;
137        }
138        match k {
139            "limit" if !saw_limit => saw_limit = true,
140            "after_id" if !saw_after_id => saw_after_id = true,
141            _ => return false,
142        }
143    }
144    // limit may be omitted only for bare /v1/models; when a query is present
145    // at least one allowed key must appear (already enforced by the loop).
146    true
147}
148
149/// Headers applied by the broker for Anthropic OAuth catalog proxy requests.
150/// Bearer is set separately via `bearer_auth`; this list must NOT include
151/// `x-api-key` (OAuth tokens are never sent as API keys).
152pub(crate) fn anthropic_oauth_catalog_request_headers() -> &'static [(&'static str, &'static str)] {
153    &[
154        ("anthropic-version", "2023-06-01"),
155        ("anthropic-beta", ANTHROPIC_OAUTH_BETA),
156        ("accept", "application/json"),
157    ]
158}
159
160// ── Errors ───────────────────────────────────────────────────────────────────
161
162/// Broker failures. Messages never contain credential values.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum BrokerError {
165    /// The named provider is not in any broker registry.
166    UnknownProvider(String),
167    /// A required public-client registration is not configured.
168    RegistrationRequired {
169        provider: String,
170        remediation: String,
171    },
172    /// The provider is known but has no credential configured.
173    NotConfigured(String),
174    /// The caller failed broker (machine) authentication.
175    Unauthorized,
176    /// The request was rejected by broker policy (e.g. malformed proxy path).
177    Denied(String),
178    /// The route honestly lacks a capability the request needs (e.g. tools on
179    /// a text-only cloud route). Raised pre-flight, before credentials/network.
180    UnsupportedCapability {
181        provider: String,
182        capability: String,
183    },
184    /// Transport-level failure talking to the broker or the provider.
185    Transport(String),
186    /// Credential storage/refresh failure (message is already secret-free).
187    Credential(String),
188}
189
190impl std::fmt::Display for BrokerError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            Self::UnknownProvider(p) => write!(f, "unknown provider: {p}"),
194            Self::RegistrationRequired {
195                provider,
196                remediation,
197            } => {
198                write!(f, "registration required for '{provider}': {remediation}")
199            }
200            Self::NotConfigured(p) => write!(
201                f,
202                "no credential configured for '{p}'. Run `synaps login` to add one."
203            ),
204            Self::Unauthorized => write!(f, "broker rejected machine auth"),
205            Self::Denied(msg) => write!(f, "broker denied request: {msg}"),
206            Self::UnsupportedCapability {
207                provider,
208                capability,
209            } => write!(
210                f,
211                "cloud provider '{provider}' is text-only: {capability} are not supported yet \
212                 (no credential was used and no network request was made)"
213            ),
214            Self::Transport(msg) => write!(f, "broker transport error: {msg}"),
215            Self::Credential(msg) => write!(f, "credential error: {msg}"),
216        }
217    }
218}
219
220impl std::error::Error for BrokerError {}
221
222// ── Vended types ─────────────────────────────────────────────────────────────
223
224/// An OAuth access token vended by the broker.
225///
226/// Deliberately has **no** refresh-token field — the invariant made
227/// structural. There is nowhere to put a refresh token even by mistake.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct AccessToken {
230    pub token: String,
231    /// Absolute expiry, unix-epoch milliseconds.
232    pub expires: u64,
233}
234
235/// HTTP method subset the proxy protocol supports.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum ProxyMethod {
239    Get,
240    Post,
241}
242
243/// A typed, credential-free request the broker executes on the caller's
244/// behalf against an OpenAI-compatible provider endpoint.
245///
246/// The caller names a provider and a relative path; the broker derives the
247/// destination from its pinned provider table and applies the (broker-owned)
248/// credential. Callers can never supply an absolute URL for a static-key
249/// provider, so a key can never be coaxed toward an attacker-chosen host.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct ProxyRequest {
252    /// Static provider key (e.g. `groq`) or `local`.
253    pub provider: String,
254    pub method: ProxyMethod,
255    /// Relative path joined onto the pinned base URL, e.g. `/chat/completions`.
256    pub path: String,
257    /// JSON request body (POST only).
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub body: Option<serde_json::Value>,
260    /// Request an SSE byte stream (`proxy_stream`) instead of a buffered body.
261    #[serde(default)]
262    pub stream: bool,
263    /// Typed exact-byte handoff (request-trace spec §6.2): when present this
264    /// MUST be the exact serialization of `body`, produced once by the
265    /// caller. [`LocalBroker`] sends these very bytes upstream verbatim, so
266    /// a caller-computed digest over them is a digest of the true wire body.
267    /// The field never crosses the remote broker HTTP boundary
268    /// (`serde(skip)`): a remote broker daemon re-serializes `body` on its
269    /// side, so callers on a remote path must NOT claim exact upstream
270    /// bytes. Never logged, never part of the broker wire schema.
271    #[serde(skip)]
272    pub body_bytes: Option<bytes::Bytes>,
273}
274
275impl ProxyRequest {
276    /// Validate provider identity and path shape. Fail closed on anything
277    /// that could redirect a broker-owned credential.
278    pub fn validate(&self) -> Result<(), BrokerError> {
279        if self.provider != LOCAL_PROVIDER_KEY
280            && self.provider != "xai-auth"
281            && self.provider != "github-copilot"
282            && self.provider != "google-gemini"
283            && self.provider != "openai-codex"
284            && self.provider != "anthropic"
285            && static_provider(&self.provider).is_none()
286        {
287            return Err(BrokerError::UnknownProvider(self.provider.clone()));
288        }
289        if !self.path.starts_with('/') {
290            return Err(BrokerError::Denied("proxy path must be relative".into()));
291        }
292        if self.path.contains("://") || self.path.contains("..") {
293            return Err(BrokerError::Denied(
294                "proxy path is not a plain relative path".into(),
295            ));
296        }
297        // Per-provider endpoint allowlist: a signed proxy request can only
298        // reach the cataloged inference/model paths, never other same-host
299        // endpoints (key management, billing, admin, …).
300        let oauth_path_allowed = self.provider == "xai-auth" && self.path == "/responses"
301            || self.provider == "github-copilot"
302                && matches!(
303                    self.path.as_str(),
304                    "/models" | "/chat/completions" | "/responses"
305                )
306            || self.provider == "google-gemini" && is_allowed_google_gemini_path(&self.path)
307            || self.provider == "openai-codex"
308                && self.method == ProxyMethod::Get
309                && is_allowed_openai_codex_path(&self.path)
310            || self.provider == "anthropic"
311                && self.method == ProxyMethod::Get
312                && is_allowed_anthropic_path(&self.path);
313        if !oauth_path_allowed && !allowed_proxy_paths(&self.provider).contains(&self.path.as_str())
314        {
315            return Err(BrokerError::Denied(format!(
316                "proxy path '{}' is not in the provider's endpoint allowlist",
317                self.path
318            )));
319        }
320        // Size cap over the effective wire body. The exact-byte handoff is
321        // authoritative whenever present — even if `body` is unset, those
322        // bytes are what `LocalBroker` would send upstream, so they must
323        // never bypass the cap (fail closed).
324        let size = match (&self.body_bytes, &self.body) {
325            // Exact-byte handoff: the bytes ARE the wire body.
326            (Some(bytes), _) => Some(bytes.len()),
327            (None, Some(body)) => Some(
328                serde_json::to_vec(body)
329                    .map(|v| v.len())
330                    .unwrap_or(usize::MAX),
331            ),
332            (None, None) => None,
333        };
334        if let Some(size) = size {
335            if size > MAX_PROXY_REQUEST_BYTES {
336                return Err(BrokerError::Denied(format!(
337                    "request body exceeds the {MAX_PROXY_REQUEST_BYTES}-byte broker limit"
338                )));
339            }
340        }
341        // Coherence invariant (debug/test only — never a release hot-path
342        // re-serialization): when both representations are present,
343        // `body_bytes` MUST parse back to exactly `body`. A divergence
344        // means a caller bypassed [`ProxyRequest::post_json_exact`] and the
345        // digested bytes would not describe the JSON the broker validated.
346        #[cfg(any(test, debug_assertions))]
347        if let (Some(bytes), Some(body)) = (&self.body_bytes, &self.body) {
348            let parsed = serde_json::from_slice::<serde_json::Value>(bytes).ok();
349            if parsed.as_ref() != Some(body) {
350                return Err(BrokerError::Denied(
351                    "body_bytes does not match the JSON body (exact-byte handoff incoherent)"
352                        .into(),
353                ));
354            }
355        }
356        Ok(())
357    }
358
359    /// Build a POST proxy request whose JSON body is serialized **exactly
360    /// once**: the returned [`bytes::Bytes`] handle shares the same buffer
361    /// stored in `body_bytes` (ref-counted clone, no copy), so a
362    /// caller-side digest over it is a digest of the true wire body on the
363    /// local-broker path. This is the sanctioned way to populate
364    /// `body_bytes` — going through it makes a `body`/`body_bytes`
365    /// semantic divergence unconstructible, and it never re-serializes on
366    /// the release hot path.
367    pub fn post_json_exact(
368        provider: impl Into<String>,
369        path: impl Into<String>,
370        body: serde_json::Value,
371        stream: bool,
372    ) -> Result<(Self, bytes::Bytes), BrokerError> {
373        let bytes =
374            bytes::Bytes::from(serde_json::to_vec(&body).map_err(|e| {
375                BrokerError::Denied(format!("request body failed to serialize: {e}"))
376            })?);
377        let request = Self {
378            provider: provider.into(),
379            method: ProxyMethod::Post,
380            path: path.into(),
381            body: Some(body),
382            stream,
383            body_bytes: Some(bytes.clone()),
384        };
385        Ok((request, bytes))
386    }
387}
388
389/// Buffered (non-streaming) proxy result: upstream status + body text.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ProxyResponse {
392    pub status: u16,
393    pub body: String,
394}
395
396/// Streaming proxy result: raw upstream bytes (SSE), post-authorization.
397pub type ProxyByteStream = Pin<Box<dyn Stream<Item = Result<bytes::Bytes, BrokerError>> + Send>>;
398
399/// What kind of credential a provider uses. Never carries key material.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum CredentialKind {
403    OAuth,
404    StaticKey,
405    LocalEndpoint,
406}
407
408/// Non-secret capability/status row for one provider.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct ProviderStatus {
411    pub key: String,
412    pub name: String,
413    pub kind: CredentialKind,
414    /// Whether a credential is available through the broker.
415    pub configured: bool,
416}
417
418/// Non-secret display status for one static key (settings UI).
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub enum StaticKeyStatus {
421    NotSet,
422    /// Configured via broker storage or login config; carries a masked,
423    /// non-reversible preview like `gsk-…7f2a`.
424    Configured {
425        masked: String,
426    },
427    /// Available from the process environment (behind the broker boundary).
428    FromEnv,
429}
430
431impl StaticKeyStatus {
432    pub fn is_configured(&self) -> bool {
433        !matches!(self, StaticKeyStatus::NotSet)
434    }
435}
436
437// ── The broker contract ──────────────────────────────────────────────────────
438
439/// Normalized dynamic cloud model returned across local/remote broker boundaries.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct CloudCatalogEntry {
442    pub provider: CloudProviderId,
443    pub id: String,
444    pub display_name: String,
445    /// Opaque broker route identifier; never a provider, account, project, or resource name.
446    pub context_ref: String,
447    pub context_label: String,
448    pub stale: bool,
449    /// Unix epoch milliseconds when this catalog snapshot was fetched.
450    pub fetched_at: u64,
451}
452
453/// Credential-free normalized cloud invocation event.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(tag = "type", rename_all = "snake_case")]
456pub enum CloudEvent {
457    TextDelta {
458        delta: String,
459    },
460    ToolArguments {
461        id: String,
462        name: Option<String>,
463        delta: String,
464    },
465    Usage {
466        input_tokens: u64,
467        output_tokens: u64,
468    },
469    Done,
470}
471pub type CloudEventStream = Pin<Box<dyn Stream<Item = Result<CloudEvent, BrokerError>> + Send>>;
472
473/// The typed credential broker protocol. One implementation runs in-process
474/// ([`LocalBroker`]) so normal local use needs no daemon; the other talks to a
475/// remote `synaps auth-broker` over authenticated HTTP(S) ([`RemoteBroker`]).
476#[async_trait]
477pub trait CredentialBroker: Send + Sync {
478    /// Vend a fresh OAuth access token (token + expiry ONLY).
479    async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError>;
480
481    /// Execute a non-streaming provider request with the broker-owned key.
482    async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError>;
483
484    /// Execute a streaming (SSE) provider request with the broker-owned key.
485    /// Returns the byte stream only when the upstream response is successful;
486    /// error statuses are read (bounded) and surfaced as [`BrokerError`].
487    async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError>;
488
489    /// Typed operation: fetch the Anthropic account-usage summary.
490    ///
491    /// The destination URL is pinned broker-side and the OAuth access token
492    /// is resolved and attached behind the boundary — callers receive usage
493    /// JSON only and never see a token or `auth.json`. This is deliberately
494    /// NOT a generic OAuth proxy: one operation, one fixed endpoint.
495    async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError>;
496
497    /// Typed cloud catalog. Credentials and provider authority remain broker-owned.
498    async fn cloud_catalog(
499        &self,
500        provider: CloudProviderId,
501        context_ref: &str,
502        allow_stale: bool,
503    ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
504        let _ = (context_ref, allow_stale);
505        Err(BrokerError::NotConfigured(provider.to_string()))
506    }
507
508    /// Typed cloud invocation. The canonical model and normalized request are the
509    /// only caller-controlled inputs; hosts, auth and signing remain broker-owned.
510    async fn cloud_invoke(
511        &self,
512        provider: CloudProviderId,
513        context_ref: &str,
514        model_id: &str,
515        request: InvokeRequest,
516    ) -> Result<CloudEventStream, BrokerError> {
517        let _ = (context_ref, model_id, request);
518        Err(BrokerError::NotConfigured(provider.to_string()))
519    }
520
521    /// Non-secret provider capability/status list.
522    async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError>;
523}
524
525// ── Local (in-process) broker ────────────────────────────────────────────────
526
527#[async_trait]
528pub trait CloudBackend: Send + Sync {
529    async fn catalog(
530        &self,
531        provider: CloudProviderId,
532        context_ref: &str,
533        allow_stale: bool,
534    ) -> Result<Vec<CloudCatalogEntry>, BrokerError>;
535    async fn invoke(
536        &self,
537        provider: CloudProviderId,
538        context_ref: &str,
539        model_id: &str,
540        request: InvokeRequest,
541    ) -> Result<CloudEventStream, BrokerError>;
542}
543
544#[derive(Clone)]
545struct ProductionCloudBackend {
546    /// Dedicated credential-bearing client: redirects are always disabled and
547    /// both connect and whole-request time are bounded, independent of callers.
548    http: reqwest::Client,
549    refresh_lock: Arc<tokio::sync::Mutex<()>>,
550}
551impl ProductionCloudBackend {
552    fn new(_http: reqwest::Client) -> Self {
553        let http = reqwest::Client::builder()
554            .redirect(reqwest::redirect::Policy::none())
555            .connect_timeout(Duration::from_secs(10))
556            .timeout(Duration::from_secs(90))
557            .build()
558            .expect("cloud HTTPS client configuration is valid");
559        Self {
560            http,
561            refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
562        }
563    }
564
565    fn state(&self, provider: CloudProviderId) -> Result<serde_json::Value, BrokerError> {
566        storage::load_cloud_state(provider.as_str())
567            .map_err(BrokerError::Credential)?
568            .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))
569    }
570
571    fn context_ref(&self, provider: CloudProviderId) -> Result<String, BrokerError> {
572        use sha2::{Digest, Sha256};
573        let state = self.state(provider)?;
574        let public = serde_json::to_vec(&state["config"])
575            .map_err(|_| BrokerError::Credential("invalid cloud context".into()))?;
576        let digest = Sha256::digest([provider.as_str().as_bytes(), &public].concat());
577        Ok(format!(
578            "ctx-{}",
579            digest[..16]
580                .iter()
581                .map(|b| format!("{b:02x}"))
582                .collect::<String>()
583        ))
584    }
585
586    fn validate_context(
587        &self,
588        provider: CloudProviderId,
589        supplied: &str,
590    ) -> Result<String, BrokerError> {
591        let opaque = self.context_ref(provider)?;
592        // The canonical provider id is accepted only as the catalog bootstrap
593        // selector. Every returned entry and persisted model route uses opaque.
594        if supplied == provider.as_str() || supplied == opaque {
595            Ok(opaque)
596        } else {
597            Err(BrokerError::Denied(
598                "cloud context does not match stored provider".into(),
599            ))
600        }
601    }
602
603    async fn aws(
604        &self,
605    ) -> Result<super::aws_bedrock::AwsBedrockBroker<super::aws_bedrock::AwsHttpApi>, BrokerError>
606    {
607        #[derive(Deserialize)]
608        struct State {
609            config: super::cloud::AwsBedrockConfig,
610            access_key: String,
611            secret_key: String,
612            session_token: String,
613            expires_at: u64,
614            registered_client: serde_json::Value,
615            sso_access_token: String,
616            sso_refresh_token: Option<String>,
617            sso_expires_at: u64,
618        }
619        let decode = |value| {
620            serde_json::from_value::<State>(value)
621                .map_err(|_| BrokerError::Credential("invalid AWS broker state".into()))
622        };
623        let mut raw = self.state(CloudProviderId::AwsBedrock)?;
624        let now = std::time::SystemTime::now()
625            .duration_since(std::time::UNIX_EPOCH)
626            .unwrap_or_default()
627            .as_millis() as u64;
628        let mut s = decode(raw.clone())?;
629        if s.expires_at <= now + 60_000 {
630            // Role credentials are shared mutable broker state. Serialize refresh,
631            // then reload so waiters consume the winner's atomic commit.
632            let _guard = self.refresh_lock.lock().await;
633            raw = self.state(CloudProviderId::AwsBedrock)?;
634            s = decode(raw.clone())?;
635            if s.expires_at <= now + 60_000 {
636                let api =
637                    super::aws_bedrock::AwsHttpApi::new(self.http.clone(), &s.config.sso_region);
638                if s.sso_expires_at <= now + 60_000 {
639                    let refresh = s
640                        .sso_refresh_token
641                        .as_deref()
642                        .filter(|v| !v.is_empty())
643                        .ok_or_else(|| {
644                            BrokerError::Credential("AWS SSO session expired; login again".into())
645                        })?;
646                    let client = super::aws_bedrock::RegisteredClient::new(
647                        s.registered_client["id"].as_str().ok_or_else(|| {
648                            BrokerError::Credential("invalid AWS client registration".into())
649                        })?,
650                        s.registered_client["secret"].as_str().ok_or_else(|| {
651                            BrokerError::Credential("invalid AWS client registration".into())
652                        })?,
653                        s.registered_client["expires_at"].as_u64().ok_or_else(|| {
654                            BrokerError::Credential("invalid AWS client registration".into())
655                        })?,
656                    );
657                    if client.expires_at <= now / 1000 {
658                        return Err(BrokerError::Credential(
659                            "AWS client registration expired; login again".into(),
660                        ));
661                    }
662                    use super::aws_bedrock::AwsApi;
663                    let token = api
664                        .create_token(
665                            &client,
666                            &s.config.sso_region,
667                            super::aws_bedrock::TokenGrant::RefreshToken(refresh),
668                        )
669                        .await
670                        .map_err(|_| {
671                            BrokerError::Credential("AWS SSO refresh rejected; login again".into())
672                        })?;
673                    raw["sso_access_token"] = token.access().into();
674                    if let Some(rotated) = token.refresh() {
675                        raw["sso_refresh_token"] = rotated.into();
676                    }
677                    raw["sso_expires_at"] = (now + token.expires_in * 1000).into();
678                    // A refresh token is single-use and may rotate. Commit the
679                    // refreshed SSO session before the independent role fetch;
680                    // otherwise a transient GetRoleCredentials failure loses
681                    // the only usable token and strands the persisted login.
682                    storage::save_cloud_state("aws-bedrock", &raw)
683                        .map_err(BrokerError::Credential)?;
684                    s = decode(raw.clone())?;
685                }
686                use super::aws_bedrock::AwsApi;
687                let credentials = api
688                    .get_role_credentials(
689                        &s.config.sso_region,
690                        &s.sso_access_token,
691                        &s.config.account_id,
692                        &s.config.role_name,
693                    )
694                    .await
695                    .map_err(|_| {
696                        BrokerError::Credential("AWS role refresh rejected; login again".into())
697                    })?;
698                raw["access_key"] = credentials.access_key().into();
699                raw["secret_key"] = credentials.secret_key().into();
700                raw["session_token"] = credentials.session_token().into();
701                raw["expires_at"] = credentials.expires_at.into();
702                storage::save_cloud_state("aws-bedrock", &raw).map_err(BrokerError::Credential)?;
703                s = decode(raw)?;
704            }
705        }
706        let api = super::aws_bedrock::AwsHttpApi::new(self.http.clone(), &s.config.sso_region);
707        Ok(super::aws_bedrock::AwsBedrockBroker::from_credentials(
708            api,
709            s.config,
710            super::aws_bedrock::RoleCredentials::new(
711                s.access_key,
712                s.secret_key,
713                s.session_token,
714                s.expires_at,
715            ),
716        ))
717    }
718    async fn azure_request(
719        &self,
720        state: &mut serde_json::Value,
721        audience: super::azure_openai::AzureAudience,
722    ) -> Result<String, BrokerError> {
723        let now = std::time::SystemTime::now()
724            .duration_since(std::time::UNIX_EPOCH)
725            .unwrap_or_default()
726            .as_millis() as u64;
727        let key = match audience {
728            super::azure_openai::AzureAudience::Arm => "arm",
729            super::azure_openai::AzureAudience::Inference => "inference",
730        };
731        if state[key]["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
732            return state[key]["access_token"]
733                .as_str()
734                .map(str::to_owned)
735                .ok_or_else(|| BrokerError::Credential("invalid Azure token state".into()));
736        }
737        // Serialize refresh and re-read after waiting so concurrent catalog and
738        // invocation calls reuse the single atomically persisted rotation.
739        let _refresh = self.refresh_lock.lock().await;
740        *state = self.state(CloudProviderId::AzureOpenAi)?;
741        if state[key]["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
742            return state[key]["access_token"]
743                .as_str()
744                .map(str::to_owned)
745                .ok_or_else(|| BrokerError::Credential("invalid Azure token state".into()));
746        }
747        let config: super::cloud::AzureOpenAiConfig =
748            serde_json::from_value(state["config"].clone())
749                .map_err(|_| BrokerError::Credential("invalid Azure broker state".into()))?;
750        let client_id = state["client_id"]
751            .as_str()
752            .ok_or_else(|| BrokerError::Credential("invalid Azure client registration".into()))?;
753        let refresh = state["refresh_token"]
754            .as_str()
755            .ok_or_else(|| BrokerError::Credential("invalid Azure refresh state".into()))?;
756        let reg = super::azure_openai::AzureRegistration::production(Some(client_id.into()))
757            .map_err(|e| BrokerError::Credential(e.to_string()))?;
758        let r = super::azure_openai::refresh_request(&config, &reg, audience, refresh)
759            .map_err(|e| BrokerError::Credential(e.to_string()))?;
760        let response = self
761            .http
762            .post(r.url)
763            .form(&r.form)
764            .send()
765            .await
766            .map_err(|_| BrokerError::Transport("Azure refresh failed".into()))?;
767        if !response.status().is_success() {
768            return Err(BrokerError::Credential(
769                "Azure refresh rejected; login again".into(),
770            ));
771        }
772        let wire: serde_json::Value = response
773            .json()
774            .await
775            .map_err(|_| BrokerError::Transport("invalid Azure token response".into()))?;
776        let access = wire["access_token"]
777            .as_str()
778            .filter(|v| !v.is_empty())
779            .ok_or_else(|| BrokerError::Transport("invalid Azure token response".into()))?
780            .to_owned();
781        if let Some(r) = wire["refresh_token"].as_str().filter(|v| !v.is_empty()) {
782            state["refresh_token"] = r.into();
783        }
784        state[key] = serde_json::json!({"access_token": access, "expires_at": now + wire["expires_in"].as_u64().unwrap_or(3600) * 1000});
785        storage::save_cloud_state("azure-openai", state).map_err(BrokerError::Credential)?;
786        Ok(access)
787    }
788
789    async fn vertex_request(&self, state: &mut serde_json::Value) -> Result<String, BrokerError> {
790        let now = std::time::SystemTime::now()
791            .duration_since(std::time::UNIX_EPOCH)
792            .unwrap_or_default()
793            .as_millis() as u64;
794        if state["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
795            return state["access_token"]
796                .as_str()
797                .map(str::to_owned)
798                .ok_or_else(|| BrokerError::Credential("invalid Vertex token state".into()));
799        }
800        let _refresh = self.refresh_lock.lock().await;
801        *state = self.state(CloudProviderId::GoogleVertex)?;
802        if state["expires_at"].as_u64().unwrap_or(0) > now + 60_000 {
803            return state["access_token"]
804                .as_str()
805                .map(str::to_owned)
806                .ok_or_else(|| BrokerError::Credential("invalid Vertex token state".into()));
807        }
808        let client_id = state["client_id"]
809            .as_str()
810            .ok_or_else(|| BrokerError::Credential("invalid Vertex registration".into()))?;
811        let refresh = state["refresh_token"]
812            .as_str()
813            .ok_or_else(|| BrokerError::Credential("invalid Vertex refresh state".into()))?;
814        let response = self
815            .http
816            .post(super::google_vertex::TOKEN_URL)
817            .form(&[
818                ("client_id", client_id),
819                ("grant_type", "refresh_token"),
820                ("refresh_token", refresh),
821            ])
822            .send()
823            .await
824            .map_err(|_| BrokerError::Transport("Vertex refresh failed".into()))?;
825        if !response.status().is_success() {
826            return Err(BrokerError::Credential(
827                "Vertex refresh rejected; login again".into(),
828            ));
829        }
830        let wire: serde_json::Value = response
831            .json()
832            .await
833            .map_err(|_| BrokerError::Transport("invalid Vertex token response".into()))?;
834        let access = wire["access_token"]
835            .as_str()
836            .filter(|v| !v.is_empty())
837            .ok_or_else(|| BrokerError::Transport("invalid Vertex token response".into()))?
838            .to_owned();
839        if let Some(r) = wire["refresh_token"].as_str().filter(|v| !v.is_empty()) {
840            state["refresh_token"] = r.into();
841        }
842        state["access_token"] = access.clone().into();
843        state["expires_at"] = (now + wire["expires_in"].as_u64().unwrap_or(3600) * 1000).into();
844        storage::save_cloud_state("google-vertex", state).map_err(BrokerError::Credential)?;
845        Ok(access)
846    }
847}
848
849#[async_trait]
850impl CloudBackend for ProductionCloudBackend {
851    async fn catalog(
852        &self,
853        provider: CloudProviderId,
854        context_ref: &str,
855        _allow_stale: bool,
856    ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
857        let opaque_context = self.validate_context(provider, context_ref)?;
858        let fetched_at = std::time::SystemTime::now()
859            .duration_since(std::time::UNIX_EPOCH)
860            .unwrap_or_default()
861            .as_millis() as u64;
862        match provider {
863            CloudProviderId::AwsBedrock => {
864                let broker = self.aws().await?;
865                let label = broker.public_context().bedrock_region;
866                broker
867                    .catalog()
868                    .await
869                    .map_err(|_| {
870                        BrokerError::Transport("AWS catalog failed (details redacted)".into())
871                    })
872                    .map(|xs| {
873                        xs.into_iter()
874                            .map(|x| CloudCatalogEntry {
875                                provider,
876                                id: x.id,
877                                display_name: x.display_name,
878                                context_ref: opaque_context.clone(),
879                                context_label: label.clone(),
880                                stale: false,
881                                fetched_at,
882                            })
883                            .collect()
884                    })
885            }
886            CloudProviderId::AzureOpenAi => {
887                let mut state = self.state(provider)?;
888                let config: super::cloud::AzureOpenAiConfig =
889                    serde_json::from_value(state["config"].clone()).map_err(|_| {
890                        BrokerError::Credential("invalid Azure broker state".into())
891                    })?;
892                let token = self
893                    .azure_request(&mut state, super::azure_openai::AzureAudience::Arm)
894                    .await?;
895                let mut discovery =
896                    super::azure_openai::DeploymentDiscovery::new(config.clone(), 20, 1000);
897                let mut url = Some(discovery.initial_url());
898                while let Some(next) = url {
899                    let r = self
900                        .http
901                        .get(next)
902                        .bearer_auth(&token)
903                        .send()
904                        .await
905                        .map_err(|_| BrokerError::Transport("Azure catalog failed".into()))?;
906                    if !r.status().is_success() {
907                        return Err(BrokerError::Transport("Azure catalog rejected".into()));
908                    }
909                    let body = read_body_capped(r, MAX_CLOUD_CATALOG_BODY_BYTES)
910                        .await
911                        .map_err(|_| {
912                            BrokerError::Transport("Azure catalog exceeded body limit".into())
913                        })?;
914                    url = discovery
915                        .accept_page(&body)
916                        .map_err(|_| BrokerError::Transport("invalid Azure catalog".into()))?;
917                }
918                discovery
919                    .finish()
920                    .map_err(|_| BrokerError::Transport("Azure catalog unavailable".into()))
921                    .map(|xs| {
922                        xs.into_iter()
923                            .map(|x| CloudCatalogEntry {
924                                provider,
925                                id: x.id,
926                                display_name: x.display_name,
927                                context_ref: opaque_context.clone(),
928                                context_label: format!(
929                                    "{}/{}",
930                                    config.resource_name, config.resource_group
931                                ),
932                                stale: false,
933                                fetched_at,
934                            })
935                            .collect()
936                    })
937            }
938            CloudProviderId::GoogleVertex => {
939                let mut state = self.state(provider)?;
940                let token = self.vertex_request(&mut state).await?;
941                let config: super::cloud::GoogleVertexConfig =
942                    serde_json::from_value(state["config"].clone()).map_err(|_| {
943                        BrokerError::Credential("invalid Vertex broker state".into())
944                    })?;
945                let mut page: Option<String> = None;
946                let mut out = Vec::new();
947                let mut seen = std::collections::HashSet::new();
948                for _ in 0..20 {
949                    let mut url=format!("https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models",config.location,config.project_id,config.location);
950                    if let Some(p) = &page {
951                        let mut parsed = url::Url::parse(&url).map_err(|_| {
952                            BrokerError::Transport("invalid Vertex catalog URL".into())
953                        })?;
954                        parsed.query_pairs_mut().append_pair("pageToken", p);
955                        url = parsed.into();
956                    }
957                    let r = self
958                        .http
959                        .get(url)
960                        .bearer_auth(&token)
961                        .send()
962                        .await
963                        .map_err(|_| BrokerError::Transport("Vertex catalog failed".into()))?;
964                    if !r.status().is_success() {
965                        return Err(BrokerError::Transport("Vertex catalog rejected".into()));
966                    }
967                    let v = cloud_catalog_json(r).await?;
968                    for m in v["publisherModels"].as_array().into_iter().flatten() {
969                        if let Some(name) = m["name"]
970                            .as_str()
971                            .filter(|n| n.starts_with("publishers/google/models/"))
972                        {
973                            if out.len() >= MAX_CLOUD_CATALOG_ENTRIES {
974                                return Err(BrokerError::Transport(
975                                    "Vertex catalog exceeded entry limit".into(),
976                                ));
977                            }
978                            if !out.iter().any(|entry: &CloudCatalogEntry| {
979                                entry.id == format!("google-vertex/{name}")
980                            }) {
981                                out.push(CloudCatalogEntry {
982                                    provider,
983                                    id: format!("google-vertex/{name}"),
984                                    display_name: m["displayName"].as_str().unwrap_or(name).into(),
985                                    context_ref: opaque_context.clone(),
986                                    context_label: format!(
987                                        "{}/{}",
988                                        config.project_id, config.location
989                                    ),
990                                    stale: false,
991                                    fetched_at,
992                                });
993                            }
994                        }
995                    }
996                    page = v["nextPageToken"].as_str().map(str::to_owned);
997                    match &page {
998                        Some(p) if seen.insert(p.clone()) => {}
999                        Some(_) => {
1000                            return Err(BrokerError::Transport("Vertex pagination loop".into()))
1001                        }
1002                        None => break,
1003                    }
1004                }
1005                if out.is_empty() {
1006                    Err(BrokerError::Transport("Vertex catalog is empty".into()))
1007                } else {
1008                    Ok(out)
1009                }
1010            }
1011        }
1012    }
1013    async fn invoke(
1014        &self,
1015        provider: CloudProviderId,
1016        context_ref: &str,
1017        model_id: &str,
1018        request: InvokeRequest,
1019    ) -> Result<CloudEventStream, BrokerError> {
1020        self.validate_context(provider, context_ref)?;
1021        if !request.tools.is_empty() {
1022            return Err(BrokerError::Denied(
1023                "tools are not yet supported by cloud providers".into(),
1024            ));
1025        }
1026        let authorized = self.catalog(provider, context_ref, false).await?;
1027        if !authorized.iter().any(|entry| entry.id == model_id) {
1028            return Err(BrokerError::Denied(
1029                "model is not present in the current provider catalog".into(),
1030            ));
1031        }
1032        match provider {
1033            CloudProviderId::AwsBedrock => {
1034                let broker = self.aws().await?;
1035                if request.stream {
1036                    use futures::StreamExt;
1037                    let stream = broker
1038                        .converse_stream(model_id, request)
1039                        .await
1040                        .map_err(|_| {
1041                            BrokerError::Transport(
1042                                "AWS invocation failed (details redacted)".into(),
1043                            )
1044                        })?
1045                        .map(|event| {
1046                            event
1047                                .map_err(|_| {
1048                                    BrokerError::Transport(
1049                                        "AWS stream failed (details redacted)".into(),
1050                                    )
1051                                })
1052                                .map(|e| match e {
1053                                    super::aws_bedrock::ConverseEvent::TextDelta(delta) => {
1054                                        CloudEvent::TextDelta { delta }
1055                                    }
1056                                    super::aws_bedrock::ConverseEvent::ToolArguments {
1057                                        id,
1058                                        delta,
1059                                    } => CloudEvent::ToolArguments {
1060                                        id,
1061                                        name: None,
1062                                        delta,
1063                                    },
1064                                    super::aws_bedrock::ConverseEvent::Usage(u) => {
1065                                        CloudEvent::Usage {
1066                                            input_tokens: u.input_tokens,
1067                                            output_tokens: u.output_tokens,
1068                                        }
1069                                    }
1070                                    super::aws_bedrock::ConverseEvent::Done => CloudEvent::Done,
1071                                })
1072                        });
1073                    Ok(Box::pin(stream))
1074                } else {
1075                    let o = broker.converse(model_id, request).await.map_err(|_| {
1076                        BrokerError::Transport("AWS invocation failed (details redacted)".into())
1077                    })?;
1078                    Ok(Box::pin(futures::stream::iter(vec![
1079                        Ok(CloudEvent::TextDelta { delta: o.text }),
1080                        Ok(CloudEvent::Usage {
1081                            input_tokens: o.usage.input_tokens,
1082                            output_tokens: o.usage.output_tokens,
1083                        }),
1084                        Ok(CloudEvent::Done),
1085                    ])))
1086                }
1087            }
1088            CloudProviderId::AzureOpenAi => {
1089                let mut state = self.state(provider)?;
1090                let token = self
1091                    .azure_request(&mut state, super::azure_openai::AzureAudience::Inference)
1092                    .await?;
1093                let endpoint = super::azure_openai::AzureEndpoint::parse(
1094                    state["endpoint"]
1095                        .as_str()
1096                        .ok_or_else(|| BrokerError::Credential("invalid Azure endpoint".into()))?,
1097                )
1098                .map_err(|_| BrokerError::Credential("invalid Azure endpoint".into()))?;
1099                let deployment = model_id
1100                    .strip_prefix("azure-openai/")
1101                    .ok_or_else(|| BrokerError::Denied("invalid Azure model".into()))?;
1102                let body = serde_json::json!({"input":request.messages.into_iter().map(|m|serde_json::json!({"role":match m.role{super::cloud::MessageRole::Assistant=>"assistant",super::cloud::MessageRole::System=>"system",_=>"user"},"content":m.content})).collect::<Vec<_>>(),"stream":request.stream});
1103                let rr = super::azure_openai::responses_request(
1104                    &endpoint,
1105                    deployment,
1106                    &serde_json::to_vec(&body).unwrap(),
1107                )
1108                .map_err(|_| BrokerError::Denied("invalid Azure request".into()))?;
1109                let r = self
1110                    .http
1111                    .post(rr.url)
1112                    .bearer_auth(token)
1113                    .header("content-type", "application/json")
1114                    .body(rr.body)
1115                    .send()
1116                    .await
1117                    .map_err(|_| BrokerError::Transport("Azure invocation failed".into()))?;
1118                if !r.status().is_success() {
1119                    return Err(BrokerError::Transport("Azure invocation rejected".into()));
1120                }
1121                if request.stream {
1122                    use futures::StreamExt;
1123                    use std::sync::{
1124                        atomic::{AtomicBool, Ordering},
1125                        Arc,
1126                    };
1127                    let terminal = Arc::new(AtomicBool::new(false));
1128                    let seen = terminal.clone();
1129                    let events = sse_json_stream(r).filter_map(move |item| {
1130                        let seen = seen.clone();
1131                        async move {
1132                            match item {
1133                                Ok(Some(v)) => v["delta"]
1134                                    .as_str()
1135                                    .or_else(|| v["text"].as_str())
1136                                    .map(|delta| {
1137                                        Ok(CloudEvent::TextDelta {
1138                                            delta: delta.into(),
1139                                        })
1140                                    }),
1141                                Ok(None) => {
1142                                    seen.store(true, Ordering::SeqCst);
1143                                    Some(Ok(CloudEvent::Done))
1144                                }
1145                                Err(error) => Some(Err(error)),
1146                            }
1147                        }
1148                    });
1149                    let stream = events.chain(futures::stream::unfold(
1150                        (terminal, false),
1151                        |(terminal, emitted)| async move {
1152                            if terminal.load(Ordering::SeqCst) || emitted {
1153                                None
1154                            } else {
1155                                Some((
1156                                    Err(BrokerError::Transport(
1157                                        "Azure stream ended without [DONE]".into(),
1158                                    )),
1159                                    (terminal, true),
1160                                ))
1161                            }
1162                        },
1163                    ));
1164                    Ok(Box::pin(stream))
1165                } else {
1166                    let text = read_body_capped(r, MAX_CLOUD_STREAM_EVENT_BYTES).await?;
1167                    let v: serde_json::Value = serde_json::from_str(&text)
1168                        .map_err(|_| BrokerError::Transport("invalid Azure response".into()))?;
1169                    let delta = v["output_text"]
1170                        .as_str()
1171                        .ok_or_else(|| {
1172                            BrokerError::Transport("Azure response omitted output".into())
1173                        })?
1174                        .to_owned();
1175                    Ok(Box::pin(futures::stream::iter(vec![
1176                        Ok(CloudEvent::TextDelta { delta }),
1177                        Ok(CloudEvent::Done),
1178                    ])))
1179                }
1180            }
1181            CloudProviderId::GoogleVertex => {
1182                let mut state = self.state(provider)?;
1183                let token = self.vertex_request(&mut state).await?;
1184                let config: super::cloud::GoogleVertexConfig =
1185                    serde_json::from_value(state["config"].clone())
1186                        .map_err(|_| BrokerError::Credential("invalid Vertex state".into()))?;
1187                let model = model_id
1188                    .strip_prefix("google-vertex/publishers/google/models/")
1189                    .filter(|m| {
1190                        m.bytes()
1191                            .all(|b| b.is_ascii_alphanumeric() || b"-._".contains(&b))
1192                    })
1193                    .ok_or_else(|| BrokerError::Denied("invalid Vertex model".into()))?;
1194                let action = if request.stream {
1195                    "streamGenerateContent?alt=sse"
1196                } else {
1197                    "generateContent"
1198                };
1199                let url=format!("https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/{}:{}",config.location,config.project_id,config.location,model,action);
1200                let body = serde_json::json!({"contents":request.messages.into_iter().map(|m|serde_json::json!({"role":if matches!(m.role,super::cloud::MessageRole::Assistant){"model"}else{"user"},"parts":[{"text":m.content}]})).collect::<Vec<_>>()});
1201                let r = self
1202                    .http
1203                    .post(url)
1204                    .bearer_auth(token)
1205                    .json(&body)
1206                    .send()
1207                    .await
1208                    .map_err(|_| BrokerError::Transport("Vertex invocation failed".into()))?;
1209                if !r.status().is_success() {
1210                    return Err(BrokerError::Transport("Vertex invocation rejected".into()));
1211                }
1212                if request.stream {
1213                    use futures::StreamExt;
1214                    let events = sse_json_stream(r).map(|item| {
1215                        item.and_then(|value| {
1216                            value.ok_or_else(|| {
1217                                BrokerError::Transport(
1218                                    "Vertex stream terminated without a provider event".into(),
1219                                )
1220                            })
1221                        })
1222                    });
1223                    use std::sync::{
1224                        atomic::{AtomicBool, Ordering},
1225                        Arc,
1226                    };
1227                    let terminal = Arc::new(AtomicBool::new(false));
1228                    let terminal_in_events = terminal.clone();
1229                    let stream = events
1230                        .flat_map(move |item| {
1231                            let mut out = Vec::new();
1232                            match item {
1233                                Ok(v) => {
1234                                    if terminal_in_events.load(Ordering::SeqCst) {
1235                                        out.push(Err(BrokerError::Transport(
1236                                            "Vertex emitted data after terminal metadata".into(),
1237                                        )));
1238                                        return futures::stream::iter(out);
1239                                    }
1240                                    for c in v["candidates"].as_array().into_iter().flatten() {
1241                                        for p in
1242                                            c["content"]["parts"].as_array().into_iter().flatten()
1243                                        {
1244                                            if let Some(delta) = p["text"].as_str() {
1245                                                out.push(Ok(CloudEvent::TextDelta {
1246                                                    delta: delta.into(),
1247                                                }));
1248                                            }
1249                                        }
1250                                        if let Some(reason) = c["finishReason"].as_str() {
1251                                            const VALID: &[&str] = &[
1252                                                "STOP",
1253                                                "MAX_TOKENS",
1254                                                "SAFETY",
1255                                                "RECITATION",
1256                                                "OTHER",
1257                                                "BLOCKLIST",
1258                                                "PROHIBITED_CONTENT",
1259                                                "SPII",
1260                                                "MALFORMED_FUNCTION_CALL",
1261                                            ];
1262                                            if !VALID.contains(&reason) {
1263                                                out.push(Err(BrokerError::Transport(
1264                                                    "Vertex returned an invalid finish reason"
1265                                                        .into(),
1266                                                )));
1267                                            } else if terminal_in_events
1268                                                .swap(true, Ordering::SeqCst)
1269                                            {
1270                                                out.push(Err(BrokerError::Transport(
1271                                                    "Vertex returned duplicate terminal metadata"
1272                                                        .into(),
1273                                                )));
1274                                            } else {
1275                                                out.push(Ok(CloudEvent::Done));
1276                                            }
1277                                        }
1278                                    }
1279                                    if let Some(u) = v.get("usageMetadata") {
1280                                        out.push(Ok(CloudEvent::Usage {
1281                                            input_tokens: u["promptTokenCount"]
1282                                                .as_u64()
1283                                                .unwrap_or(0),
1284                                            output_tokens: u["candidatesTokenCount"]
1285                                                .as_u64()
1286                                                .unwrap_or(0),
1287                                        }));
1288                                    }
1289                                }
1290                                Err(error) => out.push(Err(error)),
1291                            }
1292                            futures::stream::iter(out)
1293                        })
1294                        .chain(futures::stream::unfold(
1295                            (terminal, false),
1296                            |(terminal, emitted)| async move {
1297                                if terminal.load(Ordering::SeqCst) || emitted {
1298                                    None
1299                                } else {
1300                                    Some((
1301                                        Err(BrokerError::Transport(
1302                                            "Vertex stream ended without finish metadata".into(),
1303                                        )),
1304                                        (terminal, true),
1305                                    ))
1306                                }
1307                            },
1308                        ));
1309                    Ok(Box::pin(stream))
1310                } else {
1311                    let text = read_body_capped(r, MAX_CLOUD_STREAM_EVENT_BYTES).await?;
1312                    let v: serde_json::Value = serde_json::from_str(&text)
1313                        .map_err(|_| BrokerError::Transport("invalid Vertex response".into()))?;
1314                    let mut out = Vec::new();
1315                    for c in v["candidates"].as_array().into_iter().flatten() {
1316                        for p in c["content"]["parts"].as_array().into_iter().flatten() {
1317                            if let Some(delta) = p["text"].as_str() {
1318                                out.push(Ok(CloudEvent::TextDelta {
1319                                    delta: delta.into(),
1320                                }));
1321                            }
1322                        }
1323                    }
1324                    out.push(Ok(CloudEvent::Done));
1325                    Ok(Box::pin(futures::stream::iter(out)))
1326                }
1327            }
1328        }
1329    }
1330}
1331
1332const MAX_CLOUD_CATALOG_BODY_BYTES: usize = 2 * 1024 * 1024;
1333const MAX_CLOUD_CATALOG_ENTRIES: usize = 10_000;
1334const MAX_CLOUD_STREAM_EVENT_BYTES: usize = 1024 * 1024;
1335
1336/// Incremental SSE parser. It retains only one bounded event, yields as soon as
1337/// an event delimiter arrives, and owns the response so dropping the consumer
1338/// cancels the upstream request.
1339fn sse_json_stream(
1340    resp: reqwest::Response,
1341) -> Pin<Box<dyn Stream<Item = Result<Option<serde_json::Value>, BrokerError>> + Send>> {
1342    use futures::StreamExt;
1343    let chunks = Box::pin(resp.bytes_stream());
1344    Box::pin(futures::stream::unfold(
1345        (chunks, Vec::<u8>::new(), false),
1346        |(mut chunks, mut buf, done)| async move {
1347            if done {
1348                return None;
1349            }
1350            loop {
1351                if let Some(end) = buf.windows(2).position(|w| w == b"\n\n") {
1352                    let frame: Vec<u8> = buf.drain(..end + 2).collect();
1353                    let text = match std::str::from_utf8(&frame[..end]) {
1354                        Ok(v) => v,
1355                        Err(_) => {
1356                            return Some((
1357                                Err(BrokerError::Transport("invalid SSE encoding".into())),
1358                                (chunks, buf, true),
1359                            ))
1360                        }
1361                    };
1362                    let data = text
1363                        .lines()
1364                        .filter_map(|line| line.strip_prefix("data:"))
1365                        .map(str::trim_start)
1366                        .collect::<Vec<_>>()
1367                        .join("\n");
1368                    if data.is_empty() {
1369                        continue;
1370                    }
1371                    if data == "[DONE]" {
1372                        return Some((Ok(None), (chunks, buf, true)));
1373                    }
1374                    let value = serde_json::from_str(&data)
1375                        .map_err(|_| BrokerError::Transport("invalid SSE event".into()));
1376                    let terminal = value.is_err();
1377                    return Some((value.map(Some), (chunks, buf, terminal)));
1378                }
1379                match chunks.next().await {
1380                    Some(Ok(chunk)) if buf.len() + chunk.len() <= MAX_CLOUD_STREAM_EVENT_BYTES => {
1381                        buf.extend_from_slice(&chunk)
1382                    }
1383                    Some(_) => {
1384                        return Some((
1385                            Err(BrokerError::Transport(
1386                                "cloud stream event exceeded limit".into(),
1387                            )),
1388                            (chunks, buf, true),
1389                        ))
1390                    }
1391                    None if buf.is_empty() => return None,
1392                    None => {
1393                        return Some((
1394                            Err(BrokerError::Transport("truncated SSE event".into())),
1395                            (chunks, buf, true),
1396                        ))
1397                    }
1398                }
1399            }
1400        },
1401    ))
1402}
1403
1404/// Read one catalog page without allowing an upstream to allocate an unbounded body.
1405async fn cloud_catalog_json(resp: reqwest::Response) -> Result<serde_json::Value, BrokerError> {
1406    let body = read_body_capped(resp, MAX_CLOUD_CATALOG_BODY_BYTES).await?;
1407    serde_json::from_str(&body)
1408        .map_err(|_| BrokerError::Transport("invalid cloud catalog response".into()))
1409}
1410
1411/// In-process credential broker. This module is the ONLY place runtime-serving
1412/// code may read `auth.json`, `provider.<key>` config values, or credential
1413/// environment variables.
1414pub struct LocalBroker {
1415    http: reqwest::Client,
1416    /// Test seam: overrides the local endpoint URL without env/config.
1417    local_base_url: Option<String>,
1418    /// Test seam: overrides the pinned Anthropic usage URL.
1419    anthropic_usage_url: Option<String>,
1420    /// Test seam: overrides the pinned cloudcode-pa base URL.
1421    google_gemini_base_url: Option<String>,
1422    /// Time budget for buffered (non-streaming) requests.
1423    request_timeout: Duration,
1424    /// Buffered response size cap.
1425    max_response_bytes: usize,
1426    cloud_backend: Option<Arc<dyn CloudBackend>>,
1427}
1428
1429impl LocalBroker {
1430    pub fn new(http: reqwest::Client) -> Self {
1431        let cloud_backend = Arc::new(ProductionCloudBackend::new(http.clone()));
1432        Self {
1433            http,
1434            local_base_url: None,
1435            anthropic_usage_url: None,
1436            google_gemini_base_url: None,
1437            request_timeout: PROXY_REQUEST_TIMEOUT,
1438            max_response_bytes: MAX_PROXY_RESPONSE_BYTES,
1439            cloud_backend: Some(cloud_backend),
1440        }
1441    }
1442
1443    pub fn with_cloud_backend(mut self, backend: Arc<dyn CloudBackend>) -> Self {
1444        self.cloud_backend = Some(backend);
1445        self
1446    }
1447
1448    /// Test/embedding seam: pin the `local` provider endpoint explicitly.
1449    pub fn with_local_base_url(http: reqwest::Client, base_url: impl Into<String>) -> Self {
1450        Self {
1451            local_base_url: Some(base_url.into()),
1452            ..Self::new(http)
1453        }
1454    }
1455
1456    /// Test seam: point the pinned Google Code Assist host at a loopback fake.
1457    /// Production code must never call this — it only relaxes the base URL,
1458    /// not any of the path allowlist / bearer / redirect-denial invariants.
1459    #[doc(hidden)]
1460    pub fn with_google_gemini_base_url_for_tests(
1461        http: reqwest::Client,
1462        base_url: impl Into<String>,
1463    ) -> Self {
1464        Self {
1465            google_gemini_base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1466            ..Self::new(http)
1467        }
1468    }
1469
1470    /// Test seam: point the pinned Anthropic usage operation at a fake server.
1471    #[doc(hidden)]
1472    pub fn with_anthropic_usage_url(mut self, url: impl Into<String>) -> Self {
1473        self.anthropic_usage_url = Some(url.into());
1474        self
1475    }
1476
1477    /// Test seam: shrink the buffered-request time budget.
1478    #[doc(hidden)]
1479    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
1480        self.request_timeout = timeout;
1481        self
1482    }
1483
1484    /// Test seam: shrink the buffered-response size cap.
1485    #[doc(hidden)]
1486    pub fn with_max_response_bytes(mut self, cap: usize) -> Self {
1487        self.max_response_bytes = cap;
1488        self
1489    }
1490
1491    /// Resolve (and migrate) a static provider key. Broker-owned storage wins;
1492    /// login config and env are legacy discovery surfaces that are migrated
1493    /// into broker storage on first use.
1494    fn resolve_static_key(&self, provider: &str) -> Result<String, BrokerError> {
1495        if provider == LOCAL_PROVIDER_KEY {
1496            return Ok(self.resolve_local_key());
1497        }
1498        let spec = static_provider(provider)
1499            .ok_or_else(|| BrokerError::UnknownProvider(provider.to_string()))?;
1500        if let Ok(Some(key)) = storage::load_static_key(provider) {
1501            return Ok(key);
1502        }
1503        if let Some(key) = discover_legacy_static_key(spec) {
1504            // Migration: persist into broker-owned storage. Best-effort — a
1505            // read-only home dir must not break the request itself.
1506            if let Err(e) = storage::save_static_key(provider, &key) {
1507                tracing::warn!(provider, "static key migration failed: {e}");
1508            }
1509            return Ok(key);
1510        }
1511        Err(BrokerError::NotConfigured(provider.to_string()))
1512    }
1513
1514    fn resolve_local_key(&self) -> String {
1515        storage::load_static_key(LOCAL_PROVIDER_KEY)
1516            .ok()
1517            .flatten()
1518            .or_else(|| {
1519                crate::config::get_provider_keys()
1520                    .get(LOCAL_PROVIDER_KEY)
1521                    .filter(|s| !s.is_empty())
1522                    .cloned()
1523            })
1524            .or_else(|| {
1525                std::env::var("LOCAL_API_KEY")
1526                    .ok()
1527                    .filter(|s| !s.is_empty())
1528            })
1529            .unwrap_or_else(|| "local".to_string())
1530    }
1531
1532    fn base_url_for(&self, provider: &str) -> Result<String, BrokerError> {
1533        if provider == LOCAL_PROVIDER_KEY {
1534            if let Some(url) = &self.local_base_url {
1535                return Ok(url.trim_end_matches('/').to_string());
1536            }
1537            return Ok(local_endpoint_url());
1538        }
1539        static_provider(provider)
1540            .map(|s| s.base_url.trim_end_matches('/').to_string())
1541            .ok_or_else(|| BrokerError::UnknownProvider(provider.to_string()))
1542    }
1543
1544    async fn send(&self, request: &ProxyRequest) -> Result<reqwest::Response, BrokerError> {
1545        request.validate()?;
1546        let (key, base) = if request.provider == "xai-auth" {
1547            let token = self.access_token(OAuthProviderId::Xai).await?;
1548            (token.token, "https://api.x.ai/v1".to_string())
1549        } else if request.provider == "github-copilot" {
1550            // Catalog-only OAuth proxy: short-lived Copilot session token only.
1551            // Never attach the GitHub user token (stored as OAuth refresh).
1552            let token = self.access_token(OAuthProviderId::GitHubCopilot).await?;
1553            (
1554                token.token,
1555                super::github_copilot_models_base_url().to_string(),
1556            )
1557        } else if request.provider == "google-gemini" {
1558            // Google Gemini (Code Assist) is broker-proxy-only. Refresh stays
1559            // broker-owned; runtime never receives it.
1560            let token = self.access_token(OAuthProviderId::GoogleGemini).await?;
1561            let base = self
1562                .google_gemini_base_url
1563                .clone()
1564                .unwrap_or_else(|| GOOGLE_GEMINI_CODE_ASSIST_BASE_URL.to_string());
1565            (token.token, base)
1566        } else if request.provider == "openai-codex" {
1567            // Catalog-only OAuth proxy for ChatGPT backend models. Access token
1568            // never leaves the broker; account header is derived broker-side.
1569            let token = self.access_token(OAuthProviderId::OpenAiCodex).await?;
1570            (token.token, OPENAI_CODEX_BACKEND_BASE_URL.to_string())
1571        } else if request.provider == "anthropic" {
1572            // Catalog-only OAuth proxy for Anthropic /v1/models pagination.
1573            // Keeps the access token broker-owned (no runtime token vending).
1574            let token = self.access_token(OAuthProviderId::Anthropic).await?;
1575            (token.token, "https://api.anthropic.com".to_string())
1576        } else {
1577            (
1578                self.resolve_static_key(&request.provider)?,
1579                self.base_url_for(&request.provider)?,
1580            )
1581        };
1582        let url = format!("{base}{}", request.path);
1583        // Deny redirects for credential-bearing OAuth catalog traffic and
1584        // google-gemini: a 3xx must not replay the bearer token off-origin.
1585        let mut builder = if request.provider == "google-gemini"
1586            || request.provider == "openai-codex"
1587            || request.provider == "anthropic"
1588        {
1589            let no_redirect = reqwest::Client::builder()
1590                .redirect(reqwest::redirect::Policy::none())
1591                .connect_timeout(Duration::from_secs(10))
1592                .build()
1593                .map_err(|e| BrokerError::Transport(format!("client build failed: {e}")))?;
1594            match request.method {
1595                ProxyMethod::Get => no_redirect.get(&url),
1596                ProxyMethod::Post => no_redirect.post(&url),
1597            }
1598        } else {
1599            match request.method {
1600                ProxyMethod::Get => self.http.get(&url),
1601                ProxyMethod::Post => self.http.post(&url),
1602            }
1603        };
1604        builder = builder.bearer_auth(&key);
1605        if request.provider == "openai-codex" {
1606            // ChatGPT backend requires the account id. Prefer the stored
1607            // credential claim; fall back to JWT extraction. Never silently omit.
1608            let account_id = super::load_provider_auth("openai-codex")
1609                .ok()
1610                .flatten()
1611                .and_then(|c| c.account_id)
1612                .filter(|s| !s.trim().is_empty())
1613                .or_else(|| super::extract_codex_account_id(&key))
1614                .ok_or_else(|| {
1615                    BrokerError::Credential(
1616                        "openai-codex credential is missing chatgpt account id".into(),
1617                    )
1618                })?;
1619            builder = builder
1620                .header("chatgpt-account-id", account_id)
1621                .header("originator", "synaps")
1622                .header("OpenAI-Beta", "responses=experimental")
1623                .header("accept", "application/json");
1624        }
1625        if request.provider == "anthropic" {
1626            // OAuth catalog: Bearer + anthropic-version + anthropic-beta.
1627            // Do NOT send the OAuth token as x-api-key.
1628            for (name, value) in anthropic_oauth_catalog_request_headers() {
1629                builder = builder.header(*name, *value);
1630            }
1631        }
1632        if request.provider == "github-copilot" {
1633            for (name, value) in super::github_copilot_models_request_headers() {
1634                builder = builder.header(*name, *value);
1635            }
1636            if request.path != "/models" {
1637                builder = builder
1638                    .header("Openai-Intent", "conversation-edits")
1639                    .header("X-Initiator", "agent");
1640            }
1641        }
1642        if request.provider == "google-gemini" {
1643            // The Code Assist reference client uses `?alt=sse` for streaming.
1644            // Match that so upstream returns line-delimited SSE frames rather
1645            // than JSON-in-one-response.
1646            if request.stream && request.path == "/v1internal:streamGenerateContent" {
1647                builder = builder.query(&[("alt", "sse")]);
1648            }
1649            builder = builder.header("user-agent", "SynapsCLI/0.6.0 (google-gemini)");
1650        }
1651        if let Some(bytes) = &request.body_bytes {
1652            // Exact-byte handoff (request-trace spec §6.2): send the very
1653            // buffer the caller serialized (and digested) — never a
1654            // re-serialization of the JSON value.
1655            builder = builder
1656                .header("content-type", "application/json")
1657                .body(bytes.clone());
1658        } else if let Some(body) = &request.body {
1659            builder = builder.json(body);
1660        }
1661        if request.stream {
1662            builder = builder.header("accept", "text/event-stream");
1663        } else {
1664            // Buffered requests get an explicit total time budget; streaming
1665            // responses are open-ended by design (SSE) and rely on the
1666            // client's connect timeout plus consumer-side backpressure.
1667            builder = builder.timeout(self.request_timeout);
1668        }
1669        builder.send().await.map_err(|e| {
1670            if e.is_connect() && request.provider == LOCAL_PROVIDER_KEY {
1671                BrokerError::Transport(format!(
1672                    "can't reach local endpoint at {url} — is Ollama/LM Studio running?"
1673                ))
1674            } else {
1675                // reqwest errors do not include the bearer header.
1676                BrokerError::Transport(format!("request to {} failed: {e}", request.provider))
1677            }
1678        })
1679    }
1680}
1681
1682// ── Bounded body handling ────────────────────────────────────────────────────
1683
1684/// Read a response body up to `cap` bytes; fail closed (no truncated JSON
1685/// masquerading as a full payload) if the upstream exceeds the cap.
1686async fn read_body_capped(resp: reqwest::Response, cap: usize) -> Result<String, BrokerError> {
1687    use futures::StreamExt;
1688    let mut buf: Vec<u8> = Vec::new();
1689    let mut stream = resp.bytes_stream();
1690    while let Some(chunk) = stream.next().await {
1691        let chunk =
1692            chunk.map_err(|e| BrokerError::Transport(format!("failed to read response: {e}")))?;
1693        if buf.len() + chunk.len() > cap {
1694            return Err(BrokerError::Transport(format!(
1695                "response body exceeded the {cap}-byte broker buffering limit"
1696            )));
1697        }
1698        buf.extend_from_slice(&chunk);
1699    }
1700    String::from_utf8(buf)
1701        .map_err(|_| BrokerError::Transport("response body was not valid UTF-8".into()))
1702}
1703
1704/// Legacy discovery for a static key: login config first, then env vars.
1705/// Only callable from inside the broker boundary.
1706fn discover_legacy_static_key(spec: &StaticProviderSpec) -> Option<String> {
1707    if let Some(v) = crate::config::get_provider_keys().get(spec.key) {
1708        if !v.is_empty() {
1709            return Some(v.clone());
1710        }
1711    }
1712    spec.env_vars
1713        .iter()
1714        .find_map(|var| std::env::var(var).ok().filter(|v| !v.is_empty()))
1715}
1716
1717/// True if a static key is available through the broker (storage, login
1718/// config, or env). Never returns the key.
1719pub fn static_key_configured(provider: &str) -> bool {
1720    if provider == LOCAL_PROVIDER_KEY {
1721        return true; // the local endpoint never requires a key
1722    }
1723    let Some(spec) = static_provider(provider) else {
1724        return false;
1725    };
1726    storage::load_static_key(provider).ok().flatten().is_some()
1727        || discover_legacy_static_key(spec).is_some()
1728}
1729
1730/// Non-secret display status for a static key: configured (with masked
1731/// preview), from-env, or not set. The mask keeps at most the first 4 and
1732/// last 4 characters and is safe to render.
1733pub fn static_key_status(provider: &str) -> StaticKeyStatus {
1734    let stored = storage::load_static_key(provider)
1735        .ok()
1736        .flatten()
1737        .or_else(|| {
1738            crate::config::get_provider_keys()
1739                .get(provider)
1740                .filter(|s| !s.is_empty())
1741                .cloned()
1742        });
1743    if let Some(key) = stored {
1744        return StaticKeyStatus::Configured {
1745            masked: mask_key(&key),
1746        };
1747    }
1748    if let Some(spec) = static_provider(provider) {
1749        if spec
1750            .env_vars
1751            .iter()
1752            .any(|var| std::env::var(var).is_ok_and(|v| !v.is_empty()))
1753        {
1754            return StaticKeyStatus::FromEnv;
1755        }
1756    }
1757    StaticKeyStatus::NotSet
1758}
1759
1760/// Masked, non-reversible preview of a key (`gsk-…7f2a`).
1761fn mask_key(key: &str) -> String {
1762    let chars: Vec<char> = key.chars().collect();
1763    if chars.len() <= 8 {
1764        return "…".to_string();
1765    }
1766    let head: String = chars[..4].iter().collect();
1767    let tail: String = chars[chars.len() - 4..].iter().collect();
1768    format!("{head}…{tail}")
1769}
1770
1771/// The set of static providers with an available credential. Non-secret.
1772pub fn configured_static_provider_keys() -> std::collections::BTreeSet<String> {
1773    STATIC_PROVIDERS
1774        .iter()
1775        .filter(|s| static_key_configured(s.key))
1776        .map(|s| s.key.to_string())
1777        .collect()
1778}
1779
1780/// The local endpoint URL (non-secret configuration: `provider.local.url`
1781/// config → `LOCAL_ENDPOINT` env → default).
1782pub fn local_endpoint_url() -> String {
1783    local_endpoint_config()
1784        .unwrap_or_else(|| LOCAL_DEFAULT_BASE_URL.to_string())
1785        .trim_end_matches('/')
1786        .to_string()
1787}
1788
1789/// The explicitly-configured local endpoint, if any (None → default in use).
1790pub fn local_endpoint_config() -> Option<String> {
1791    crate::config::get_provider_keys()
1792        .get("local.url")
1793        .filter(|s| !s.is_empty())
1794        .cloned()
1795        .or_else(|| {
1796            std::env::var("LOCAL_ENDPOINT")
1797                .ok()
1798                .filter(|s| !s.is_empty())
1799        })
1800}
1801
1802/// Comma-separated local model ids from non-secret config (`provider.local.models`).
1803pub fn local_model_ids() -> Vec<String> {
1804    crate::config::get_provider_keys()
1805        .get("local.models")
1806        .map(|value| {
1807            value
1808                .split(',')
1809                .map(|s| s.trim().to_string())
1810                .filter(|s| !s.is_empty())
1811                .collect()
1812        })
1813        .unwrap_or_default()
1814}
1815
1816/// True if any Anthropic credential is available through the broker
1817/// (OAuth login or `ANTHROPIC_API_KEY`). Non-secret answer for first-run UX.
1818pub fn anthropic_credential_available() -> bool {
1819    let oauth = load_provider_auth(OAuthProviderId::Anthropic.as_str())
1820        .ok()
1821        .flatten()
1822        .map(|c| c.auth_type == "oauth" && !c.access.is_empty())
1823        .unwrap_or(false);
1824    oauth || std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.is_empty())
1825}
1826
1827/// True if the OAuth provider has stored refreshable credentials. This is a
1828/// non-secret availability query for UI surfaces; an expired access token is
1829/// still a valid login when the broker can refresh it on first use.
1830pub fn oauth_provider_logged_in(provider: OAuthProviderId) -> bool {
1831    load_provider_auth(provider.as_str())
1832        .ok()
1833        .flatten()
1834        .is_some_and(|creds| {
1835            creds.auth_type == "oauth" && (!creds.refresh.is_empty() || !creds.access.is_empty())
1836        })
1837}
1838
1839#[async_trait]
1840impl CredentialBroker for LocalBroker {
1841    async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError> {
1842        let creds = super::ensure_fresh_provider_token(&self.http, provider)
1843            .await
1844            .map_err(BrokerError::Credential)?;
1845        // Strip to token + expiry: the refresh token stays behind the boundary.
1846        Ok(AccessToken {
1847            token: creds.access,
1848            expires: creds.expires,
1849        })
1850    }
1851
1852    async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1853        let resp = self.send(&request).await?;
1854        let status = resp.status().as_u16();
1855        let body = read_body_capped(resp, self.max_response_bytes).await?;
1856        Ok(ProxyResponse { status, body })
1857    }
1858
1859    async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1860        let mut request = request;
1861        request.stream = true;
1862        let resp = self.send(&request).await?;
1863        let status = resp.status();
1864        if !status.is_success() {
1865            // Spec §5.1: the upstream may echo the full request (prompts,
1866            // tool schemas, credentials) in its error body. Drop the body
1867            // unread — only the typed status reaches the error. The stable
1868            // `provider request failed: {status}` prefix (with canonical
1869            // reason phrase, e.g. `429 Too Many Requests`) is the transport
1870            // contract engine-side classifiers parse.
1871            drop(resp);
1872            return Err(BrokerError::Transport(format!(
1873                "provider request failed: {status}"
1874            )));
1875        }
1876        use futures::StreamExt;
1877        let stream = resp
1878            .bytes_stream()
1879            .map(|chunk| chunk.map_err(|e| BrokerError::Transport(format!("stream error: {e}"))));
1880        Ok(Box::pin(stream))
1881    }
1882
1883    async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1884        // Token resolution happens HERE, behind the boundary — the caller
1885        // never touches auth.json or sees the access token.
1886        let token = self.access_token(OAuthProviderId::Anthropic).await?;
1887        let url = self
1888            .anthropic_usage_url
1889            .as_deref()
1890            .unwrap_or(ANTHROPIC_USAGE_URL);
1891        let resp = self
1892            .http
1893            .get(url)
1894            .timeout(self.request_timeout)
1895            .bearer_auth(&token.token)
1896            .header("anthropic-beta", ANTHROPIC_OAUTH_BETA)
1897            .send()
1898            .await
1899            .map_err(|e| BrokerError::Transport(format!("usage request failed: {e}")))?;
1900        let status = resp.status();
1901        if !status.is_success() {
1902            // The error body is upstream-controlled and may echo the request;
1903            // it never enters the error (spec §5.1).
1904            drop(resp);
1905            return Err(BrokerError::Transport(format!(
1906                "usage request failed: {status}"
1907            )));
1908        }
1909        let body = read_body_capped(resp, self.max_response_bytes).await?;
1910        serde_json::from_str(&body)
1911            .map_err(|e| BrokerError::Transport(format!("invalid usage response: {e}")))
1912    }
1913
1914    async fn cloud_catalog(
1915        &self,
1916        provider: CloudProviderId,
1917        context_ref: &str,
1918        allow_stale: bool,
1919    ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
1920        self.cloud_backend
1921            .as_ref()
1922            .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))?
1923            .catalog(provider, context_ref, allow_stale)
1924            .await
1925    }
1926
1927    async fn cloud_invoke(
1928        &self,
1929        provider: CloudProviderId,
1930        context_ref: &str,
1931        model_id: &str,
1932        request: InvokeRequest,
1933    ) -> Result<CloudEventStream, BrokerError> {
1934        self.cloud_backend
1935            .as_ref()
1936            .ok_or_else(|| BrokerError::NotConfigured(provider.to_string()))?
1937            .invoke(provider, context_ref, model_id, request)
1938            .await
1939    }
1940
1941    async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError> {
1942        let mut out = Vec::new();
1943        for descriptor in super::provider::registry().iter() {
1944            out.push(ProviderStatus {
1945                key: descriptor.id.as_str().to_string(),
1946                name: descriptor.display_name.to_string(),
1947                kind: CredentialKind::OAuth,
1948                configured: load_provider_auth(descriptor.id.as_str())
1949                    .ok()
1950                    .flatten()
1951                    .is_some(),
1952            });
1953        }
1954        for spec in STATIC_PROVIDERS {
1955            out.push(ProviderStatus {
1956                key: spec.key.to_string(),
1957                name: spec.name.to_string(),
1958                kind: CredentialKind::StaticKey,
1959                configured: static_key_configured(spec.key),
1960            });
1961        }
1962        out.push(ProviderStatus {
1963            key: LOCAL_PROVIDER_KEY.to_string(),
1964            name: "Local endpoint".to_string(),
1965            kind: CredentialKind::LocalEndpoint,
1966            configured: true,
1967        });
1968        Ok(out)
1969    }
1970}
1971
1972// ── Remote broker client ─────────────────────────────────────────────────────
1973
1974/// Client for a remote `synaps auth-broker`. Presents the machine's own
1975/// bearer token; receives access tokens (OAuth) and proxied responses
1976/// (static-key providers). Never receives refresh tokens or raw keys.
1977pub struct RemoteBroker {
1978    http: reqwest::Client,
1979    endpoint: String,
1980    machine_token: String,
1981    cache: super::TokenCache,
1982}
1983
1984impl RemoteBroker {
1985    pub fn new(
1986        endpoint: impl Into<String>,
1987        machine_token: impl Into<String>,
1988        http: reqwest::Client,
1989        cache: super::TokenCache,
1990    ) -> Self {
1991        let endpoint = endpoint.into().trim_end_matches('/').to_string();
1992        // Never trust a caller-supplied redirect policy for credential-bearing
1993        // broker RPC. A redirect could otherwise replay machine auth off-origin.
1994        let http = reqwest::Client::builder()
1995            .redirect(reqwest::redirect::Policy::none())
1996            .connect_timeout(Duration::from_secs(10))
1997            .timeout(Duration::from_secs(90))
1998            .build()
1999            .unwrap_or(http);
2000        Self {
2001            http,
2002            endpoint,
2003            machine_token: machine_token.into(),
2004            cache,
2005        }
2006    }
2007
2008    async fn post_proxy(&self, request: &ProxyRequest) -> Result<reqwest::Response, BrokerError> {
2009        request.validate()?;
2010        let resp = self
2011            .http
2012            .post(format!("{}/proxy", self.endpoint))
2013            .bearer_auth(&self.machine_token)
2014            .json(request)
2015            .send()
2016            .await
2017            .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2018        match resp.status().as_u16() {
2019            401 => Err(BrokerError::Unauthorized),
2020            s @ (400 | 403) => {
2021                // A compromised broker controls every byte of its response —
2022                // including JSON `error` fields. Static status-only message;
2023                // the body is dropped unread (spec §5.1).
2024                drop(resp);
2025                Err(BrokerError::Denied(format!(
2026                    "broker rejected the request (HTTP {s})"
2027                )))
2028            }
2029            _ => Ok(resp),
2030        }
2031    }
2032}
2033
2034#[async_trait]
2035impl CredentialBroker for RemoteBroker {
2036    async fn access_token(&self, provider: OAuthProviderId) -> Result<AccessToken, BrokerError> {
2037        let fetcher = super::BrokerClient::with_client(
2038            self.endpoint.clone(),
2039            self.machine_token.clone(),
2040            self.http.clone(),
2041        );
2042        let tok = super::resolve_remote(
2043            &fetcher,
2044            &self.cache,
2045            provider.as_str(),
2046            super::DEFAULT_MARGIN_MS,
2047        )
2048        .await
2049        .map_err(BrokerError::Transport)?;
2050        Ok(AccessToken {
2051            token: tok.access_token,
2052            expires: tok.expires,
2053        })
2054    }
2055
2056    async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
2057        let mut request = request;
2058        request.stream = false;
2059        let resp = self.post_proxy(&request).await?;
2060        if !resp.status().is_success() {
2061            return Err(BrokerError::Transport(format!(
2062                "broker proxy returned HTTP {}",
2063                resp.status()
2064            )));
2065        }
2066        let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2067        serde_json::from_str::<ProxyResponse>(&body)
2068            .map_err(|e| BrokerError::Transport(format!("invalid broker proxy response: {e}")))
2069    }
2070
2071    async fn proxy_stream(&self, request: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
2072        let mut request = request;
2073        request.stream = true;
2074        let resp = self.post_proxy(&request).await?;
2075        let status = resp.status();
2076        if !status.is_success() {
2077            // Broker-controlled error body: dropped unread (spec §5.1).
2078            drop(resp);
2079            return Err(BrokerError::Transport(format!(
2080                "broker proxy stream failed: {status}"
2081            )));
2082        }
2083        use futures::StreamExt;
2084        let stream = resp
2085            .bytes_stream()
2086            .map(|chunk| chunk.map_err(|e| BrokerError::Transport(format!("stream error: {e}"))));
2087        Ok(Box::pin(stream))
2088    }
2089
2090    async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
2091        // Machine-authenticated typed operation: the remote broker resolves
2092        // the OAuth token on its side and returns usage JSON only.
2093        let resp = self
2094            .http
2095            .get(format!("{}/usage", self.endpoint))
2096            .bearer_auth(&self.machine_token)
2097            .send()
2098            .await
2099            .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2100        match resp.status().as_u16() {
2101            401 => Err(BrokerError::Unauthorized),
2102            s if !(200..300).contains(&s) => {
2103                // Broker-controlled error body: dropped unread (spec §5.1).
2104                drop(resp);
2105                Err(BrokerError::Transport(format!(
2106                    "broker usage returned HTTP {s}"
2107                )))
2108            }
2109            _ => {
2110                let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2111                serde_json::from_str(&body)
2112                    .map_err(|e| BrokerError::Transport(format!("invalid usage response: {e}")))
2113            }
2114        }
2115    }
2116
2117    async fn cloud_catalog(
2118        &self,
2119        provider: CloudProviderId,
2120        context_ref: &str,
2121        allow_stale: bool,
2122    ) -> Result<Vec<CloudCatalogEntry>, BrokerError> {
2123        let resp = self.http.post(format!("{}/cloud/catalog", self.endpoint)).bearer_auth(&self.machine_token).json(&serde_json::json!({"provider":provider,"context_ref":context_ref,"allow_stale":allow_stale})).send().await.map_err(|e| BrokerError::Transport(e.to_string()))?;
2124        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
2125            return Err(BrokerError::Unauthorized);
2126        }
2127        if !resp.status().is_success() {
2128            return Err(BrokerError::Transport(format!(
2129                "cloud catalog returned HTTP {}",
2130                resp.status()
2131            )));
2132        }
2133        let body = read_body_capped(resp, MAX_CLOUD_CATALOG_BODY_BYTES).await?;
2134        let entries: Vec<CloudCatalogEntry> = serde_json::from_str(&body)
2135            .map_err(|e| BrokerError::Transport(format!("invalid cloud catalog: {e}")))?;
2136        if entries.len() > MAX_CLOUD_CATALOG_ENTRIES
2137            || entries.iter().any(|e| {
2138                e.provider != provider || !e.context_ref.starts_with("ctx-") || e.id.len() > 512
2139            })
2140        {
2141            return Err(BrokerError::Transport(
2142                "broker returned an invalid cloud catalog".into(),
2143            ));
2144        }
2145        Ok(entries)
2146    }
2147
2148    async fn cloud_invoke(
2149        &self,
2150        provider: CloudProviderId,
2151        context_ref: &str,
2152        model_id: &str,
2153        request: InvokeRequest,
2154    ) -> Result<CloudEventStream, BrokerError> {
2155        let resp = self.http.post(format!("{}/cloud/invoke", self.endpoint)).bearer_auth(&self.machine_token).json(&serde_json::json!({"provider":provider,"context_ref":context_ref,"model_id":model_id,"request":request})).send().await.map_err(|e| BrokerError::Transport(e.to_string()))?;
2156        if !resp.status().is_success() {
2157            return Err(if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
2158                BrokerError::Unauthorized
2159            } else {
2160                BrokerError::Transport(format!("cloud invoke returned HTTP {}", resp.status()))
2161            });
2162        }
2163        use futures::StreamExt;
2164        let chunks = Box::pin(resp.bytes_stream());
2165        let stream = futures::stream::unfold(
2166            (chunks, Vec::<u8>::new(), false, false),
2167            |(mut chunks, mut buffer, done, failed)| async move {
2168                if failed {
2169                    return None;
2170                }
2171                loop {
2172                    if let Some(end) = buffer.iter().position(|b| *b == b'\n') {
2173                        let line: Vec<u8> = buffer.drain(..=end).collect();
2174                        let parsed = serde_json::from_slice::<CloudEvent>(&line[..end])
2175                            .map_err(|_| BrokerError::Transport("invalid cloud event".into()));
2176                        let event = match parsed {
2177                            Ok(CloudEvent::Done) if done => Err(BrokerError::Transport(
2178                                "duplicate cloud terminal event".into(),
2179                            )),
2180                            Ok(CloudEvent::Done) => Ok(CloudEvent::Done),
2181                            Ok(_) if done => Err(BrokerError::Transport(
2182                                "cloud data followed terminal event".into(),
2183                            )),
2184                            other => other,
2185                        };
2186                        let next_done = done || matches!(event, Ok(CloudEvent::Done));
2187                        let next_failed = event.is_err();
2188                        return Some((event, (chunks, buffer, next_done, next_failed)));
2189                    }
2190                    match chunks.next().await {
2191                        Some(Ok(chunk))
2192                            if buffer.len() + chunk.len() <= MAX_CLOUD_STREAM_EVENT_BYTES =>
2193                        {
2194                            buffer.extend_from_slice(&chunk)
2195                        }
2196                        Some(Ok(_)) => {
2197                            return Some((
2198                                Err(BrokerError::Transport("cloud event exceeded limit".into())),
2199                                (chunks, Vec::new(), done, true),
2200                            ))
2201                        }
2202                        Some(Err(_)) => {
2203                            return Some((
2204                                Err(BrokerError::Transport("broker stream failed".into())),
2205                                (chunks, Vec::new(), done, true),
2206                            ))
2207                        }
2208                        None if buffer.is_empty() && done => return None,
2209                        None if buffer.is_empty() => {
2210                            return Some((
2211                                Err(BrokerError::Transport(
2212                                    "cloud stream ended without terminal event".into(),
2213                                )),
2214                                (chunks, Vec::new(), done, true),
2215                            ))
2216                        }
2217                        None => {
2218                            return Some((
2219                                Err(BrokerError::Transport("truncated cloud event".into())),
2220                                (chunks, Vec::new(), done, true),
2221                            ))
2222                        }
2223                    }
2224                }
2225            },
2226        );
2227        Ok(Box::pin(stream))
2228    }
2229
2230    async fn capabilities(&self) -> Result<Vec<ProviderStatus>, BrokerError> {
2231        let resp = self
2232            .http
2233            .get(format!("{}/capabilities", self.endpoint))
2234            .bearer_auth(&self.machine_token)
2235            .send()
2236            .await
2237            .map_err(|e| BrokerError::Transport(format!("broker request failed: {e}")))?;
2238        match resp.status().as_u16() {
2239            401 => Err(BrokerError::Unauthorized),
2240            s if !(200..300).contains(&s) => {
2241                Err(BrokerError::Transport(format!("broker returned HTTP {s}")))
2242            }
2243            _ => {
2244                let body = read_body_capped(resp, MAX_PROXY_RESPONSE_BYTES).await?;
2245                serde_json::from_str::<Vec<ProviderStatus>>(&body).map_err(|e| {
2246                    BrokerError::Transport(format!("invalid capabilities response: {e}"))
2247                })
2248            }
2249        }
2250    }
2251}
2252
2253// ── Construction and process-wide handle ─────────────────────────────────────
2254
2255/// Build the right broker for a credential source. Local sources get the
2256/// in-process broker (no daemon needed); remote sources get the authenticated
2257/// Pre-flight capability check for a cloud route (spec §5.5). Pure function:
2258/// callers MUST run it before constructing a broker, looking up credentials,
2259/// or opening any connection. The invoke-time guard inside the broker remains
2260/// in place as defense in depth.
2261pub fn preflight_cloud_capability(
2262    provider: CloudProviderId,
2263    needs_tools: bool,
2264) -> Result<(), BrokerError> {
2265    if needs_tools && !provider.supports_tools() {
2266        return Err(BrokerError::UnsupportedCapability {
2267            provider: provider.to_string(),
2268            capability: "tools".into(),
2269        });
2270    }
2271    Ok(())
2272}
2273
2274/// remote transport. There is no third option — and no direct-read fallback.
2275pub fn broker_from_source(
2276    source: &super::CredentialSource,
2277    cache: &super::TokenCache,
2278    http: reqwest::Client,
2279) -> Arc<dyn CredentialBroker> {
2280    match source {
2281        super::CredentialSource::Local => Arc::new(LocalBroker::new(http)),
2282        super::CredentialSource::Remote {
2283            endpoint,
2284            machine_token,
2285        } => Arc::new(RemoteBroker::new(
2286            endpoint.clone(),
2287            machine_token.clone(),
2288            http,
2289            cache.clone(),
2290        )),
2291    }
2292}
2293
2294static GLOBAL_BROKER: std::sync::RwLock<Option<Arc<dyn CredentialBroker>>> =
2295    std::sync::RwLock::new(None);
2296
2297/// Install the process-wide broker (called once from runtime configuration).
2298pub fn set_global_broker(broker: Arc<dyn CredentialBroker>) {
2299    *GLOBAL_BROKER.write().expect("broker registry poisoned") = Some(broker);
2300}
2301
2302/// The process-wide broker. Defaults to the in-process [`LocalBroker`] so
2303/// normal local use never requires a separately launched daemon.
2304pub fn global_broker() -> Arc<dyn CredentialBroker> {
2305    if let Some(b) = GLOBAL_BROKER
2306        .read()
2307        .expect("broker registry poisoned")
2308        .clone()
2309    {
2310        return b;
2311    }
2312    let default: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(
2313        reqwest::Client::builder()
2314            .connect_timeout(std::time::Duration::from_secs(10))
2315            .timeout(std::time::Duration::from_secs(300))
2316            .build()
2317            .unwrap_or_default(),
2318    ));
2319    let mut guard = GLOBAL_BROKER.write().expect("broker registry poisoned");
2320    if let Some(b) = guard.clone() {
2321        return b;
2322    }
2323    *guard = Some(default.clone());
2324    default
2325}
2326
2327/// Legacy signature bridge: resolve an access token via the appropriate
2328/// broker for `source`. Kept for CLI paths (`synaps status`).
2329pub async fn broker_access_token(
2330    provider: OAuthProviderId,
2331    source: &super::CredentialSource,
2332    cache: &super::TokenCache,
2333    http: &reqwest::Client,
2334) -> Result<AccessToken, BrokerError> {
2335    broker_from_source(source, cache, http.clone())
2336        .access_token(provider)
2337        .await
2338}
2339
2340/// Non-secret map of static key display statuses for every provider,
2341/// plus the local endpoint. For settings UI snapshots.
2342pub fn static_key_status_map() -> BTreeMap<String, StaticKeyStatus> {
2343    STATIC_PROVIDERS
2344        .iter()
2345        .map(|s| (s.key.to_string(), static_key_status(s.key)))
2346        .collect()
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351    use super::*;
2352
2353    /// Spec §5.5: the pre-flight capability check is a pure function — it can
2354    /// be (and is) called before any credential lookup or network access. A
2355    /// tool-requiring request against any text-only cloud route must yield the
2356    /// typed unsupported-capability error; text-only requests pass.
2357    #[test]
2358    fn preflight_rejects_tool_requiring_cloud_routes_with_typed_error() {
2359        for provider in [
2360            CloudProviderId::AzureOpenAi,
2361            CloudProviderId::AwsBedrock,
2362            CloudProviderId::GoogleVertex,
2363        ] {
2364            let err = preflight_cloud_capability(provider, true)
2365                .expect_err("tool-requiring cloud route must fail pre-flight");
2366            assert_eq!(
2367                err,
2368                BrokerError::UnsupportedCapability {
2369                    provider: provider.to_string(),
2370                    capability: "tools".into(),
2371                }
2372            );
2373            assert!(err.to_string().contains("text-only"));
2374            assert_eq!(preflight_cloud_capability(provider, false), Ok(()));
2375        }
2376    }
2377
2378    async fn spawn_ndjson(body: &'static str) -> String {
2379        use axum::{body::Body, routing::post, Router};
2380        use bytes::Bytes;
2381        let app = Router::new().route(
2382            "/cloud/invoke",
2383            post(move || async move {
2384                let chunks = body
2385                    .as_bytes()
2386                    .chunks(3)
2387                    .map(|chunk| Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(chunk)));
2388                axum::response::Response::builder()
2389                    .header("content-type", "application/x-ndjson")
2390                    .body(Body::from_stream(futures::stream::iter(chunks)))
2391                    .unwrap()
2392            }),
2393        );
2394        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2395        let addr = listener.local_addr().unwrap();
2396        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2397        format!("http://{addr}")
2398    }
2399
2400    async fn remote_events(body: &'static str) -> Vec<Result<CloudEvent, BrokerError>> {
2401        use futures::StreamExt;
2402        let broker = RemoteBroker::new(
2403            spawn_ndjson(body).await,
2404            "opaque-machine-token",
2405            reqwest::Client::new(),
2406            super::super::TokenCache::new(),
2407        );
2408        broker
2409            .cloud_invoke(
2410                CloudProviderId::GoogleVertex,
2411                "ctx-opaque",
2412                "google-vertex/publishers/google/models/test",
2413                InvokeRequest {
2414                    messages: vec![],
2415                    tools: vec![],
2416                    stream: true,
2417                    options: Default::default(),
2418                },
2419            )
2420            .await
2421            .unwrap()
2422            .collect()
2423            .await
2424    }
2425
2426    #[tokio::test]
2427    async fn remote_ndjson_requires_exactly_one_terminal_and_stops_after_error() {
2428        let valid =
2429            remote_events("{\"type\":\"text_delta\",\"delta\":\"hi\"}\n{\"type\":\"done\"}\n")
2430                .await;
2431        assert_eq!(valid.len(), 2);
2432        assert!(matches!(valid[1], Ok(CloudEvent::Done)));
2433
2434        for body in [
2435            "{\"type\":\"text_delta\",\"delta\":\"hi\"}\n",
2436            "{\"type\":\"done\"}\n{\"type\":\"done\"}\n",
2437            "{\"type\":\"done\"}\n{\"type\":\"text_delta\",\"delta\":\"late\"}\n",
2438        ] {
2439            let events = remote_events(body).await;
2440            assert_eq!(events.iter().filter(|event| event.is_err()).count(), 1);
2441            assert!(events.last().unwrap().is_err());
2442        }
2443    }
2444
2445    /// The per-provider endpoint allowlist: a signed proxy request cannot be
2446    /// steered at other same-host endpoints (key management, billing, …).
2447    #[test]
2448    fn proxy_rejects_unlisted_same_host_paths() {
2449        for (provider, path) in [
2450            ("groq", "/v1/keys"),
2451            ("groq", "/admin"),
2452            ("openrouter", "/api/v1/auth/keys"),
2453            ("local", "/audio/speech"),
2454        ] {
2455            let req = ProxyRequest {
2456                provider: provider.into(),
2457                method: ProxyMethod::Get,
2458                path: path.into(),
2459                body: None,
2460                stream: false,
2461                body_bytes: None,
2462            };
2463            match req.validate() {
2464                Err(BrokerError::Denied(msg)) => {
2465                    assert!(msg.contains("allowlist"), "got: {msg}")
2466                }
2467                other => panic!("{provider} {path} must be denied, got {other:?}"),
2468            }
2469        }
2470        // The cataloged paths remain reachable.
2471        for path in ["/models", "/chat/completions"] {
2472            let req = ProxyRequest {
2473                provider: "groq".into(),
2474                method: ProxyMethod::Get,
2475                path: path.into(),
2476                body: None,
2477                stream: false,
2478                body_bytes: None,
2479            };
2480            assert!(req.validate().is_ok(), "{path} must be allowed");
2481        }
2482    }
2483
2484    /// Request bodies above the broker buffering limit are rejected before
2485    /// any credential resolution or upstream contact.
2486    #[test]
2487    fn proxy_rejects_oversize_request_body() {
2488        let req = ProxyRequest {
2489            provider: "groq".into(),
2490            method: ProxyMethod::Post,
2491            path: "/chat/completions".into(),
2492            body: Some(serde_json::json!({
2493                "blob": "x".repeat(MAX_PROXY_REQUEST_BYTES + 1)
2494            })),
2495            stream: false,
2496            body_bytes: None,
2497        };
2498        match req.validate() {
2499            Err(BrokerError::Denied(msg)) => assert!(msg.contains("byte"), "got: {msg}"),
2500            other => panic!("oversize body must be denied, got {other:?}"),
2501        }
2502    }
2503
2504    /// Regression: the size cap must hold over the exact-byte handoff even
2505    /// when the JSON `body` is unset — `body_bytes` alone is what
2506    /// `LocalBroker` would send upstream, so it can never bypass the limit.
2507    #[test]
2508    fn proxy_rejects_oversize_body_bytes_without_json_body() {
2509        let req = ProxyRequest {
2510            provider: "groq".into(),
2511            method: ProxyMethod::Post,
2512            path: "/chat/completions".into(),
2513            body: None,
2514            stream: false,
2515            body_bytes: Some(bytes::Bytes::from(vec![b'x'; MAX_PROXY_REQUEST_BYTES + 1])),
2516        };
2517        match req.validate() {
2518            Err(BrokerError::Denied(msg)) => assert!(msg.contains("byte"), "got: {msg}"),
2519            other => panic!("oversize body_bytes must be denied, got {other:?}"),
2520        }
2521    }
2522
2523    /// The sanctioned constructor serializes once and keeps `body` and
2524    /// `body_bytes` coherent: same buffer returned to the caller (for
2525    /// digesting) and stored on the request, parsing back to the very value.
2526    #[test]
2527    fn post_json_exact_sets_coherent_body_and_bytes() {
2528        let value = serde_json::json!({"model": "m", "stream": true});
2529        let (req, digest_bytes) =
2530            ProxyRequest::post_json_exact("groq", "/chat/completions", value.clone(), true)
2531                .expect("serializable body must construct");
2532        assert_eq!(req.method, ProxyMethod::Post);
2533        assert!(req.stream);
2534        assert_eq!(req.body.as_ref(), Some(&value));
2535        let stored = req.body_bytes.as_ref().expect("bytes must be set");
2536        assert_eq!(stored, &digest_bytes, "caller digest bytes == wire bytes");
2537        assert_eq!(
2538            serde_json::from_slice::<serde_json::Value>(stored).unwrap(),
2539            value
2540        );
2541        req.validate().expect("coherent request must validate");
2542    }
2543
2544    /// A semantically divergent `body`/`body_bytes` pair (constructed by
2545    /// hand, bypassing `post_json_exact`) is rejected by validation in
2546    /// debug/test builds — the digest would not describe the claimed body.
2547    #[test]
2548    fn mismatched_body_bytes_rejected_by_debug_validation() {
2549        let req = ProxyRequest {
2550            provider: "groq".into(),
2551            method: ProxyMethod::Post,
2552            path: "/chat/completions".into(),
2553            body: Some(serde_json::json!({"model": "claimed"})),
2554            stream: true,
2555            body_bytes: Some(bytes::Bytes::from_static(b"{\"model\":\"actually-sent\"}")),
2556        };
2557        match req.validate() {
2558            Err(BrokerError::Denied(msg)) => {
2559                assert!(msg.contains("body_bytes"), "got: {msg}");
2560            }
2561            other => panic!("incoherent handoff must be denied, got {other:?}"),
2562        }
2563    }
2564
2565    #[test]
2566    fn proxy_request_validation_fails_closed() {
2567        let ok = ProxyRequest {
2568            provider: "groq".into(),
2569            method: ProxyMethod::Post,
2570            path: "/chat/completions".into(),
2571            body: None,
2572            stream: false,
2573            body_bytes: None,
2574        };
2575        assert!(ok.validate().is_ok());
2576
2577        let unknown = ProxyRequest {
2578            provider: "evil".into(),
2579            ..ok.clone()
2580        };
2581        assert!(matches!(
2582            unknown.validate(),
2583            Err(BrokerError::UnknownProvider(_))
2584        ));
2585
2586        let absolute = ProxyRequest {
2587            path: "https://evil.example/x".into(),
2588            ..ok.clone()
2589        };
2590        assert!(matches!(absolute.validate(), Err(BrokerError::Denied(_))));
2591
2592        let traversal = ProxyRequest {
2593            path: "/../secrets".into(),
2594            ..ok.clone()
2595        };
2596        assert!(matches!(traversal.validate(), Err(BrokerError::Denied(_))));
2597
2598        let relative = ProxyRequest {
2599            path: "chat/completions".into(),
2600            ..ok
2601        };
2602        assert!(matches!(relative.validate(), Err(BrokerError::Denied(_))));
2603    }
2604
2605    /// OAuth providers stay fail-closed except reviewed catalog paths.
2606    /// Claude alias remains unknown; anthropic/openai-codex only accept
2607    /// their exact catalog allowlists (not bare `/models`).
2608    #[test]
2609    fn proxy_rejects_oauth_providers_except_codex_catalog() {
2610        // Unrecognized OAuth alias is still unknown.
2611        let claude = ProxyRequest {
2612            provider: "claude".into(),
2613            method: ProxyMethod::Get,
2614            path: "/models".into(),
2615            body: None,
2616            stream: false,
2617            body_bytes: None,
2618        };
2619        assert!(
2620            matches!(claude.validate(), Err(BrokerError::UnknownProvider(_))),
2621            "claude alias must not be proxyable"
2622        );
2623        // Anthropic bare OpenAI-style /models is denied (only /v1/models…).
2624        let anth = ProxyRequest {
2625            provider: "anthropic".into(),
2626            method: ProxyMethod::Get,
2627            path: "/models".into(),
2628            body: None,
2629            stream: false,
2630            body_bytes: None,
2631        };
2632        assert!(
2633            matches!(anth.validate(), Err(BrokerError::Denied(_))),
2634            "anthropic /models must be denied"
2635        );
2636        // openai-codex bare /models (no client_version) must be denied.
2637        let bare = ProxyRequest {
2638            provider: "openai-codex".into(),
2639            method: ProxyMethod::Get,
2640            path: "/models".into(),
2641            body: None,
2642            stream: false,
2643            body_bytes: None,
2644        };
2645        assert!(
2646            matches!(bare.validate(), Err(BrokerError::Denied(_))),
2647            "openai-codex /models without client_version must be denied"
2648        );
2649    }
2650
2651    /// Codex catalog proxy is pinned to GET /models?client_version=… only.
2652    /// Arbitrary same-host paths and query shapes are denied.
2653    #[test]
2654    fn proxy_allows_only_pinned_openai_codex_models_path() {
2655        let allowed = ProxyRequest {
2656            provider: "openai-codex".into(),
2657            method: ProxyMethod::Get,
2658            path: "/models?client_version=0.6.0".into(),
2659            body: None,
2660            stream: false,
2661            body_bytes: None,
2662        };
2663        assert!(
2664            allowed.validate().is_ok(),
2665            "exact codex models path must be allowed"
2666        );
2667
2668        for path in [
2669            "/models",
2670            "/models?client_version=",
2671            "/models?foo=1",
2672            "/models?client_version=0.6.0&extra=1",
2673            "/codex/responses",
2674            "/backend-api/models?client_version=0.6.0",
2675            "/v1/models?client_version=0.6.0",
2676            "/chat/completions",
2677        ] {
2678            let req = ProxyRequest {
2679                provider: "openai-codex".into(),
2680                method: ProxyMethod::Get,
2681                path: path.into(),
2682                body: None,
2683                stream: false,
2684                body_bytes: None,
2685            };
2686            assert!(
2687                matches!(req.validate(), Err(BrokerError::Denied(_))),
2688                "openai-codex path {path} must be denied"
2689            );
2690        }
2691
2692        // POST is never allowed for the catalog path.
2693        let post = ProxyRequest {
2694            provider: "openai-codex".into(),
2695            method: ProxyMethod::Post,
2696            path: "/models?client_version=0.6.0".into(),
2697            body: None,
2698            stream: false,
2699            body_bytes: None,
2700        };
2701        assert!(
2702            matches!(post.validate(), Err(BrokerError::Denied(_))),
2703            "POST openai-codex models must be denied"
2704        );
2705    }
2706
2707    /// Copilot is pinned to its catalog and two reviewed inference paths.
2708    #[test]
2709    fn proxy_allows_only_pinned_github_copilot_paths() {
2710        let models = ProxyRequest {
2711            provider: "github-copilot".into(),
2712            method: ProxyMethod::Get,
2713            path: "/models".into(),
2714            body: None,
2715            stream: false,
2716            body_bytes: None,
2717        };
2718        assert!(models.validate().is_ok());
2719
2720        let chat = ProxyRequest {
2721            provider: "github-copilot".into(),
2722            method: ProxyMethod::Post,
2723            path: "/chat/completions".into(),
2724            body: None,
2725            stream: false,
2726            body_bytes: None,
2727        };
2728        assert!(chat.validate().is_ok());
2729        let responses = ProxyRequest {
2730            provider: "github-copilot".into(),
2731            method: ProxyMethod::Post,
2732            path: "/responses".into(),
2733            body: None,
2734            stream: false,
2735            body_bytes: None,
2736        };
2737        assert!(responses.validate().is_ok());
2738        for path in ["/v1/messages", "/models?x=1", "/embeddings"] {
2739            let request = ProxyRequest {
2740                provider: "github-copilot".into(),
2741                method: ProxyMethod::Post,
2742                path: path.into(),
2743                body: None,
2744                stream: false,
2745                body_bytes: None,
2746            };
2747            assert!(matches!(request.validate(), Err(BrokerError::Denied(_))));
2748        }
2749    }
2750
2751    /// Anthropic catalog proxy is pinned to /v1/models (+ limit/after_id) only.
2752    #[test]
2753    fn proxy_allows_only_pinned_anthropic_models_path() {
2754        for path in [
2755            "/v1/models",
2756            "/v1/models?limit=100",
2757            "/v1/models?limit=100&after_id=claude-opus-4-7",
2758        ] {
2759            let req = ProxyRequest {
2760                provider: "anthropic".into(),
2761                method: ProxyMethod::Get,
2762                path: path.into(),
2763                body: None,
2764                stream: false,
2765                body_bytes: None,
2766            };
2767            assert!(req.validate().is_ok(), "{path} must be allowed");
2768        }
2769        for path in [
2770            "/v1/messages",
2771            "/models",
2772            "/v1/models?foo=1",
2773            "/v1/models?limit=",
2774            "/api/oauth/usage",
2775            "/v1/models?limit=100&evil=1",
2776            "/v1/models?limit=100&limit=50",
2777            "/v1/models?after_id=a&after_id=b",
2778            "/v1/models?limit=100&after_id=a&limit=50",
2779        ] {
2780            let req = ProxyRequest {
2781                provider: "anthropic".into(),
2782                method: ProxyMethod::Get,
2783                path: path.into(),
2784                body: None,
2785                stream: false,
2786                body_bytes: None,
2787            };
2788            assert!(
2789                matches!(req.validate(), Err(BrokerError::Denied(_))),
2790                "anthropic path {path} must be denied"
2791            );
2792        }
2793    }
2794
2795    #[test]
2796    fn anthropic_oauth_catalog_headers_are_bearer_version_beta_not_x_api_key() {
2797        let headers = anthropic_oauth_catalog_request_headers();
2798        let names: Vec<_> = headers.iter().map(|(n, _)| *n).collect();
2799        assert!(names.contains(&"anthropic-version"));
2800        assert!(names.contains(&"anthropic-beta"));
2801        assert!(names.contains(&"accept"));
2802        assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("x-api-key")));
2803        assert!(!names
2804            .iter()
2805            .any(|n| n.eq_ignore_ascii_case("authorization")));
2806        let beta = headers
2807            .iter()
2808            .find(|(n, _)| *n == "anthropic-beta")
2809            .map(|(_, v)| *v)
2810            .expect("beta");
2811        assert_eq!(beta, "oauth-2025-04-20");
2812    }
2813
2814    #[test]
2815    fn anthropic_models_path_round_trip_and_rejects_duplicates() {
2816        assert!(is_allowed_anthropic_path("/v1/models"));
2817        assert!(is_allowed_anthropic_path("/v1/models?limit=100"));
2818        assert!(is_allowed_anthropic_path(
2819            "/v1/models?limit=100&after_id=claude-opus-4-7"
2820        ));
2821        assert!(!is_allowed_anthropic_path("/v1/models?limit=100&limit=50"));
2822        assert!(!is_allowed_anthropic_path(
2823            "/v1/models?after_id=a&after_id=b"
2824        ));
2825        // Round-trip the catalog helper path shape used by the engine.
2826        let page0 = "/v1/models?limit=100";
2827        let page1 = "/v1/models?limit=100&after_id=model-1";
2828        assert!(is_allowed_anthropic_path(page0));
2829        assert!(is_allowed_anthropic_path(page1));
2830    }
2831
2832    /// google-gemini is pinned to the reviewed cloudcode-pa v1internal methods.
2833    #[test]
2834    fn proxy_allows_only_pinned_google_gemini_paths() {
2835        for path in [
2836            "/v1internal:loadCodeAssist",
2837            "/v1internal:onboardUser",
2838            "/v1internal:streamGenerateContent",
2839            "/v1internal:countTokens",
2840            "/v1internal/operations/op-12345",
2841        ] {
2842            let req = ProxyRequest {
2843                provider: "google-gemini".into(),
2844                method: ProxyMethod::Post,
2845                path: path.into(),
2846                body: None,
2847                stream: false,
2848                body_bytes: None,
2849            };
2850            assert!(req.validate().is_ok(), "{path} must be allowed");
2851        }
2852        for path in [
2853            // No arbitrary same-host methods.
2854            "/v1internal:listExperiments",
2855            "/v1internal:fetchAdminControls",
2856            "/v1internal:setCodeAssistGlobalUserSetting",
2857            "/v1internal:generateContent",
2858            // No unrelated versions.
2859            "/v2/models",
2860            "/v1beta/models",
2861            // No path traversal or root probe.
2862            "/",
2863            "/v1internal:",
2864        ] {
2865            let req = ProxyRequest {
2866                provider: "google-gemini".into(),
2867                method: ProxyMethod::Post,
2868                path: path.into(),
2869                body: None,
2870                stream: false,
2871                body_bytes: None,
2872            };
2873            assert!(
2874                matches!(req.validate(), Err(BrokerError::Denied(_))),
2875                "{path} must be denied"
2876            );
2877        }
2878    }
2879
2880    #[test]
2881    fn access_token_type_has_no_refresh_field() {
2882        // Structural invariant: deserializing a broker response that includes
2883        // a refresh token silently drops it — there is no field to hold one.
2884        let t: AccessToken =
2885            serde_json::from_str(r#"{"token":"sk-x","expires":123,"refresh":"MUST-NOT-EXIST"}"#)
2886                .unwrap();
2887        let round = serde_json::to_value(&t).unwrap();
2888        assert_eq!(round.get("refresh"), None);
2889        assert_eq!(round["token"], "sk-x");
2890    }
2891
2892    #[test]
2893    fn broker_error_display_never_echoes_values() {
2894        // Errors carry provider names and status text only.
2895        let e = BrokerError::NotConfigured("groq".into());
2896        assert!(!format!("{e}").contains("sk-"));
2897        let e = BrokerError::Unauthorized;
2898        assert_eq!(format!("{e}"), "broker rejected machine auth");
2899    }
2900
2901    #[test]
2902    fn mask_key_is_short_and_lossy() {
2903        assert_eq!(mask_key("gsk-live-1234567890abcdef"), "gsk-…cdef");
2904        assert_eq!(mask_key("short"), "…");
2905    }
2906
2907    #[test]
2908    fn proxy_request_serde_roundtrip() {
2909        let req = ProxyRequest {
2910            provider: "openrouter".into(),
2911            method: ProxyMethod::Get,
2912            path: "/models".into(),
2913            body: Some(serde_json::json!({"a": 1})),
2914            stream: true,
2915            body_bytes: None,
2916        };
2917        let json = serde_json::to_string(&req).unwrap();
2918        let back: ProxyRequest = serde_json::from_str(&json).unwrap();
2919        assert_eq!(back.provider, "openrouter");
2920        assert!(back.stream);
2921        assert!(matches!(back.method, ProxyMethod::Get));
2922    }
2923
2924    // ── LocalBroker streaming/proxy behavior against a fake upstream ─────────
2925
2926    async fn spawn_upstream(app: axum::Router) -> String {
2927        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2928        let addr = listener.local_addr().unwrap();
2929        tokio::spawn(async move {
2930            axum::serve(listener, app).await.unwrap();
2931        });
2932        format!("http://{addr}")
2933    }
2934
2935    /// Streaming forwarding: SSE bytes from the upstream arrive unmodified
2936    /// through `proxy_stream`, and the Authorization header applied is the
2937    /// broker's — the caller never supplied one.
2938    #[tokio::test]
2939    async fn local_broker_streams_local_endpoint_and_applies_key() {
2940        use axum::routing::post;
2941        let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2942        let seen = seen_auth.clone();
2943        let app = axum::Router::new().route(
2944            "/chat/completions",
2945            post(move |headers: axum::http::HeaderMap| {
2946                let seen = seen.clone();
2947                async move {
2948                    *seen.lock().unwrap() = headers
2949                        .get("authorization")
2950                        .and_then(|v| v.to_str().ok())
2951                        .unwrap_or("")
2952                        .to_string();
2953                    (
2954                        [("content-type", "text/event-stream")],
2955                        "data: {\"x\":1}\n\ndata: [DONE]\n\n",
2956                    )
2957                }
2958            }),
2959        );
2960        let url = spawn_upstream(app).await;
2961        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
2962        let mut stream = broker
2963            .proxy_stream(ProxyRequest {
2964                provider: LOCAL_PROVIDER_KEY.into(),
2965                method: ProxyMethod::Post,
2966                path: "/chat/completions".into(),
2967                body: Some(serde_json::json!({"model": "m"})),
2968                stream: true,
2969                body_bytes: None,
2970            })
2971            .await
2972            .expect("stream must open");
2973        use futures::StreamExt;
2974        let mut collected = Vec::new();
2975        while let Some(chunk) = stream.next().await {
2976            collected.extend_from_slice(&chunk.unwrap());
2977        }
2978        let text = String::from_utf8(collected).unwrap();
2979        assert!(text.contains("data: {\"x\":1}"));
2980        assert!(text.contains("[DONE]"));
2981        // Key applied broker-side (default local key), not by the caller.
2982        assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2983    }
2984
2985    /// A non-2xx upstream response never yields a stream — it becomes a typed
2986    /// error whose text is the provider's status/body (no key material).
2987    #[tokio::test]
2988    async fn local_broker_stream_error_is_typed_and_keyless() {
2989        use axum::routing::post;
2990        let app = axum::Router::new().route(
2991            "/chat/completions",
2992            post(|| async {
2993                (
2994                    axum::http::StatusCode::UNAUTHORIZED,
2995                    "{\"error\":\"bad key\"}",
2996                )
2997            }),
2998        );
2999        let url = spawn_upstream(app).await;
3000        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3001        let err = match broker
3002            .proxy_stream(ProxyRequest {
3003                provider: LOCAL_PROVIDER_KEY.into(),
3004                method: ProxyMethod::Post,
3005                path: "/chat/completions".into(),
3006                body: None,
3007                stream: true,
3008                body_bytes: None,
3009            })
3010            .await
3011        {
3012            Err(e) => e,
3013            Ok(_) => panic!("non-2xx upstream must not yield a stream"),
3014        };
3015        let msg = format!("{err}");
3016        assert!(msg.contains("401"), "got: {msg}");
3017        assert!(
3018            !msg.to_lowercase().contains("bearer"),
3019            "no auth material in errors"
3020        );
3021    }
3022
3023    /// Non-streaming proxy returns upstream status + body verbatim.
3024    #[tokio::test]
3025    async fn local_broker_proxy_returns_status_and_body() {
3026        use axum::routing::get;
3027        let app =
3028            axum::Router::new().route("/models", get(|| async { "{\"data\":[{\"id\":\"m1\"}]}" }));
3029        let url = spawn_upstream(app).await;
3030        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3031        let resp = broker
3032            .proxy(ProxyRequest {
3033                provider: LOCAL_PROVIDER_KEY.into(),
3034                method: ProxyMethod::Get,
3035                path: "/models".into(),
3036                body: None,
3037                stream: false,
3038                body_bytes: None,
3039            })
3040            .await
3041            .unwrap();
3042        assert_eq!(resp.status, 200);
3043        assert!(resp.body.contains("m1"));
3044    }
3045
3046    /// A buffered response above the broker cap fails closed instead of
3047    /// ballooning memory or returning silently truncated JSON.
3048    #[tokio::test]
3049    async fn local_broker_proxy_rejects_oversize_response() {
3050        use axum::routing::get;
3051        let app = axum::Router::new().route("/models", get(|| async { "x".repeat(4096) }));
3052        let url = spawn_upstream(app).await;
3053        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url)
3054            .with_max_response_bytes(64);
3055        let err = broker
3056            .proxy(ProxyRequest {
3057                provider: LOCAL_PROVIDER_KEY.into(),
3058                method: ProxyMethod::Get,
3059                path: "/models".into(),
3060                body: None,
3061                stream: false,
3062                body_bytes: None,
3063            })
3064            .await
3065            .expect_err("oversize body must be rejected");
3066        let msg = format!("{err}");
3067        assert!(msg.contains("limit"), "got: {msg}");
3068    }
3069
3070    /// Upstream error bodies never reach the caller — a hostile provider
3071    /// cannot flood the caller or inject terminal escapes via error text.
3072    #[tokio::test]
3073    async fn local_broker_stream_error_is_status_only() {
3074        use axum::routing::post;
3075        let app = axum::Router::new().route(
3076            "/chat/completions",
3077            post(|| async {
3078                (
3079                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3080                    format!("\x1b[2J{}", "E".repeat(64 * 1024)),
3081                )
3082            }),
3083        );
3084        let url = spawn_upstream(app).await;
3085        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3086        let err = broker
3087            .proxy_stream(ProxyRequest {
3088                provider: LOCAL_PROVIDER_KEY.into(),
3089                method: ProxyMethod::Post,
3090                path: "/chat/completions".into(),
3091                body: None,
3092                stream: true,
3093                body_bytes: None,
3094            })
3095            .await
3096            .err()
3097            .expect("non-2xx upstream must not yield a stream");
3098        let msg = format!("{err}");
3099        assert!(
3100            msg.len() < 700,
3101            "error must be bounded, got {} bytes",
3102            msg.len()
3103        );
3104        assert!(!msg.contains('\x1b'), "control chars must be stripped");
3105        assert!(msg.contains("500"));
3106    }
3107
3108    /// Buffered proxy requests carry an explicit time budget: a hung upstream
3109    /// becomes a typed transport error, not an indefinite stall.
3110    #[tokio::test]
3111    async fn local_broker_buffered_request_times_out() {
3112        use axum::routing::get;
3113        let app = axum::Router::new().route(
3114            "/models",
3115            get(|| async {
3116                tokio::time::sleep(Duration::from_secs(30)).await;
3117                "too late"
3118            }),
3119        );
3120        let url = spawn_upstream(app).await;
3121        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url)
3122            .with_request_timeout(Duration::from_millis(200));
3123        let started = std::time::Instant::now();
3124        let err = broker
3125            .proxy(ProxyRequest {
3126                provider: LOCAL_PROVIDER_KEY.into(),
3127                method: ProxyMethod::Get,
3128                path: "/models".into(),
3129                body: None,
3130                stream: false,
3131                body_bytes: None,
3132            })
3133            .await
3134            .expect_err("hung upstream must time out");
3135        assert!(matches!(err, BrokerError::Transport(_)));
3136        assert!(
3137            started.elapsed() < Duration::from_secs(5),
3138            "timeout must be enforced promptly"
3139        );
3140    }
3141
3142    /// Local in-process behavior: the process-wide broker exists without any
3143    /// daemon being launched and answers capability queries immediately.
3144    #[tokio::test]
3145    async fn global_broker_defaults_to_in_process_local() {
3146        let broker = global_broker();
3147        let caps = broker
3148            .capabilities()
3149            .await
3150            .expect("in-process broker needs no daemon");
3151        assert!(caps.iter().any(|c| c.key == LOCAL_PROVIDER_KEY));
3152        // Idempotent: repeated calls return an installed instance.
3153        let again = global_broker();
3154        assert!(again.capabilities().await.is_ok());
3155    }
3156
3157    // ── Upstream/broker error-body disclosure (spec §5.1) ────────────────────
3158    //
3159    // An upstream may echo the full request (prompts, tool input_schema,
3160    // credentials) in its error body, and a compromised remote broker controls
3161    // every byte of its responses including JSON `error` fields. No
3162    // provider/broker-controlled bytes may reach BrokerError Display.
3163
3164    /// Unique sentinel: if this appears in any error Display, provider bytes
3165    /// leaked across the broker boundary.
3166    const HOSTILE_SENTINEL: &str = "ZX9-HOSTILE-SENTINEL-7Q";
3167
3168    /// A hostile error body shaped like an echoed request: sentinel, marker,
3169    /// and request-shaped fields a provider could reflect back.
3170    fn hostile_body() -> String {
3171        format!(
3172            "{{\"error\":{{\"message\":\"ECHOED {HOSTILE_SENTINEL} \
3173             {{\\\"messages\\\":[{{\\\"content\\\":\\\"my secret prompt\\\"}}],\
3174             \\\"tools\\\":[{{\\\"input_schema\\\":{{}}}}]}}\"}}}}"
3175        )
3176    }
3177
3178    /// A hostile broker-style JSON body whose top-level `error` field is
3179    /// attacker-controlled (compromised remote broker).
3180    fn hostile_broker_json() -> String {
3181        format!(
3182            "{{\"error\":\"ECHOED {HOSTILE_SENTINEL} input_schema \
3183             my secret prompt\"}}"
3184        )
3185    }
3186
3187    fn assert_no_hostile_bytes(msg: &str) {
3188        assert!(
3189            !msg.contains(HOSTILE_SENTINEL),
3190            "sentinel leaked into error: {msg}"
3191        );
3192        assert!(!msg.contains("ECHOED"), "echoed body leaked: {msg}");
3193        assert!(
3194            !msg.contains("input_schema"),
3195            "request-shaped data leaked: {msg}"
3196        );
3197        assert!(
3198            !msg.contains("secret prompt"),
3199            "prompt content leaked: {msg}"
3200        );
3201    }
3202
3203    /// LocalBroker::proxy_stream: a non-2xx upstream body must never enter the
3204    /// error, while the stable `provider request failed: {status}` prefix and
3205    /// typed Transport category are retained for engine-side classification
3206    /// (including Gemini 429 detection on the status reason phrase).
3207    #[tokio::test]
3208    async fn local_broker_stream_error_omits_upstream_body() {
3209        use axum::routing::post;
3210        let app = axum::Router::new().route(
3211            "/chat/completions",
3212            post(|| async {
3213                (
3214                    axum::http::StatusCode::TOO_MANY_REQUESTS,
3215                    format!("\x1b[2J{}", hostile_body()),
3216                )
3217            }),
3218        );
3219        let url = spawn_upstream(app).await;
3220        let broker = LocalBroker::with_local_base_url(reqwest::Client::new(), url);
3221        let err = broker
3222            .proxy_stream(ProxyRequest {
3223                provider: LOCAL_PROVIDER_KEY.into(),
3224                method: ProxyMethod::Post,
3225                path: "/chat/completions".into(),
3226                body: None,
3227                stream: true,
3228                body_bytes: None,
3229            })
3230            .await
3231            .err()
3232            .expect("non-2xx upstream must not yield a stream");
3233        assert!(matches!(err, BrokerError::Transport(_)));
3234        let msg = format!("{err}");
3235        assert!(
3236            msg.contains("provider request failed: 429 Too Many Requests"),
3237            "status prefix must survive for engine classification: {msg}"
3238        );
3239        assert!(
3240            !msg.contains('\x1b'),
3241            "escapes must not reach errors: {msg}"
3242        );
3243        assert_no_hostile_bytes(&msg);
3244    }
3245
3246    /// LocalBroker::anthropic_usage: a non-2xx usage-endpoint body must never
3247    /// enter the error; operation + status survive. Serial: mutates
3248    /// `SYNAPS_BASE_DIR`, which other `#[serial]` tests also depend on.
3249    #[tokio::test]
3250    #[serial_test::serial]
3251    async fn local_broker_usage_error_omits_upstream_body() {
3252        use axum::routing::get;
3253        let app = axum::Router::new().route(
3254            "/usage",
3255            get(|| async {
3256                (
3257                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3258                    hostile_body(),
3259                )
3260            }),
3261        );
3262        let url = spawn_upstream(app).await;
3263
3264        // Provide fresh Anthropic OAuth creds behind an isolated base dir so
3265        // token resolution succeeds without touching the real auth store.
3266        // Redirect via HOME (not SYNAPS_BASE_DIR): the non-serial
3267        // `config::tests::test_base_dir` asserts only the `.synaps-cli`
3268        // suffix, which every HOME-derived base dir preserves.
3269        let dir = tempfile::tempdir().unwrap();
3270        let base = dir.path().join(".synaps-cli");
3271        std::fs::create_dir_all(&base).unwrap();
3272        let creds = crate::auth::OAuthCredentials {
3273            auth_type: "oauth".into(),
3274            refresh: String::new(),
3275            access: "test-access-token".into(),
3276            expires: crate::epoch_millis() + 3_600_000,
3277            account_id: None,
3278        };
3279        super::super::storage::save_provider_auth_at_test_hook(
3280            &base.join("auth.json"),
3281            "anthropic",
3282            &creds,
3283        )
3284        .unwrap();
3285        let old_base = std::env::var("SYNAPS_BASE_DIR").ok();
3286        let old_home = std::env::var("HOME").ok();
3287        std::env::remove_var("SYNAPS_BASE_DIR");
3288        std::env::set_var("HOME", dir.path());
3289
3290        let broker = LocalBroker::new(reqwest::Client::new())
3291            .with_anthropic_usage_url(format!("{url}/usage"));
3292        let result = broker.anthropic_usage().await;
3293
3294        match old_home {
3295            Some(v) => std::env::set_var("HOME", v),
3296            None => std::env::remove_var("HOME"),
3297        }
3298        if let Some(v) = old_base {
3299            std::env::set_var("SYNAPS_BASE_DIR", v);
3300        }
3301
3302        let err = result.expect_err("non-2xx usage response must fail");
3303        assert!(matches!(err, BrokerError::Transport(_)));
3304        let msg = format!("{err}");
3305        assert!(
3306            msg.contains("usage request failed") && msg.contains("500"),
3307            "operation + status must survive: {msg}"
3308        );
3309        assert_no_hostile_bytes(&msg);
3310    }
3311
3312    async fn spawn_remote_broker_error(status: u16) -> RemoteBroker {
3313        use axum::routing::{get, post};
3314        let code = axum::http::StatusCode::from_u16(status).unwrap();
3315        let app = axum::Router::new()
3316            .route(
3317                "/proxy",
3318                post(move || async move { (code, hostile_broker_json()) }),
3319            )
3320            .route(
3321                "/usage",
3322                get(move || async move { (code, hostile_broker_json()) }),
3323            );
3324        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3325        let addr = listener.local_addr().unwrap();
3326        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3327        RemoteBroker::new(
3328            format!("http://{addr}"),
3329            "opaque-machine-token",
3330            reqwest::Client::new(),
3331            super::super::TokenCache::new(),
3332        )
3333    }
3334
3335    fn groq_request(stream: bool) -> ProxyRequest {
3336        ProxyRequest {
3337            provider: "groq".into(),
3338            method: ProxyMethod::Post,
3339            path: "/chat/completions".into(),
3340            body: None,
3341            stream,
3342            body_bytes: None,
3343        }
3344    }
3345
3346    /// RemoteBroker 400/403: a compromised broker's `error` field must not
3347    /// reach the Denied Display; the typed Denied category survives.
3348    #[tokio::test]
3349    async fn remote_broker_denied_omits_broker_error_body() {
3350        for status in [400u16, 403] {
3351            let broker = spawn_remote_broker_error(status).await;
3352            let err = broker
3353                .proxy(groq_request(false))
3354                .await
3355                .expect_err("4xx broker response must fail");
3356            assert!(
3357                matches!(err, BrokerError::Denied(_)),
3358                "typed category must survive, got {err:?}"
3359            );
3360            let msg = format!("{err}");
3361            assert!(msg.contains(&status.to_string()), "status lost: {msg}");
3362            assert_no_hostile_bytes(&msg);
3363        }
3364    }
3365
3366    /// RemoteBroker::proxy_stream: a non-2xx broker body must never enter the
3367    /// error; operation + status survive.
3368    #[tokio::test]
3369    async fn remote_broker_stream_error_omits_broker_body() {
3370        let broker = spawn_remote_broker_error(500).await;
3371        let err = broker
3372            .proxy_stream(groq_request(true))
3373            .await
3374            .err()
3375            .expect("non-2xx broker response must not yield a stream");
3376        assert!(matches!(err, BrokerError::Transport(_)));
3377        let msg = format!("{err}");
3378        assert!(
3379            msg.contains("broker proxy stream failed") && msg.contains("500"),
3380            "operation + status must survive: {msg}"
3381        );
3382        assert_no_hostile_bytes(&msg);
3383    }
3384
3385    /// RemoteBroker::anthropic_usage: a non-2xx broker body must never enter
3386    /// the error; operation + status survive.
3387    #[tokio::test]
3388    async fn remote_broker_usage_error_omits_broker_body() {
3389        let broker = spawn_remote_broker_error(502).await;
3390        let err = broker
3391            .anthropic_usage()
3392            .await
3393            .expect_err("non-2xx broker response must fail");
3394        assert!(matches!(err, BrokerError::Transport(_)));
3395        let msg = format!("{err}");
3396        assert!(
3397            msg.contains("broker usage returned HTTP 502"),
3398            "operation + status must survive: {msg}"
3399        );
3400        assert_no_hostile_bytes(&msg);
3401    }
3402
3403    /// Capability rows carry configured-ness only — the serialized form can
3404    /// never contain key material because there is no field for it.
3405    #[tokio::test]
3406    async fn capabilities_expose_no_secret_fields() {
3407        let broker = LocalBroker::new(reqwest::Client::new());
3408        let caps = broker.capabilities().await.unwrap();
3409        assert!(caps
3410            .iter()
3411            .any(|c| c.key == "anthropic" && c.kind == CredentialKind::OAuth));
3412        assert!(caps
3413            .iter()
3414            .any(|c| c.key == "groq" && c.kind == CredentialKind::StaticKey));
3415        let json = serde_json::to_string(&caps).unwrap();
3416        for field in ["key\":", "name\":", "kind\":", "configured\":"] {
3417            assert!(json.contains(field));
3418        }
3419        assert!(!json.contains("refresh"));
3420        assert!(!json.contains("access"));
3421    }
3422}