Skip to main content

agentd/intel/
endpoints.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The intelligence endpoint *list* and per-endpoint credentials. RFC 0018 §3.1/§3.2.
3//!
4//! `--intelligence` / `AGENTD_INTELLIGENCE` is an **ordered, comma-separated
5//! list** (`https://gw-a.example,https://gw-b.example`); list order IS failover
6//! priority (`eps[0]` is the primary). Each element is parsed by the HTTPS-only
7//! transport resolver (target-vision pivot; plaintext `http://` is loopback-only
8//! for dev/tests) and resolves its **own** credential by env name (§3.2). A
9//! single-element list is exactly RFC 0006 — the failover/breaker machinery is
10//! inert with one endpoint.
11//!
12//! Per-endpoint credential naming (RFC 0014 §6.4 / §3.2): the default
13//! `AGENTD_INTELLIGENCE_TOKEN` (≡ endpoint 1), then `_2`, `_3`, … (1-indexed by
14//! list position). Each has a `…_FILE` variant read through [`crate::sec::secret`]
15//! (the secret-file reader landed in 0017-A). **The list URI carries no key**;
16//! the resolved value is never logged/serialized (the `Secret`-no-`Serialize`
17//! property holds — we hold it as an opaque `String` only in the dialer, never
18//! in a config/manifest).
19
20use std::time::Duration;
21
22use super::client::{IntelError, Provider, Transport, resolve};
23use super::health::{BreakerConfig, HealthRecord};
24
25/// The default per-endpoint credential env var (≡ endpoint 1, RFC 0018 §3.2) —
26/// the branded spelling agentd documents/emits.
27const TOKEN_ENV: &str = "AGENTD_INTELLIGENCE_TOKEN";
28
29/// The neutral (de-branded) credential env var (ACC SPEC L4 / env-convention.json)
30/// accepted as an input alias for [`TOKEN_ENV`]. Credentials path only — the
31/// resolved value is still held opaquely and never logged/serialized (L5).
32const TOKEN_ENV_NEUTRAL: &str = "AGENT_INTELLIGENCE_TOKEN";
33
34/// A single resolved endpoint: its transport + the per-request HTTP framing +
35/// its resolved credential + live health/breaker state.
36pub struct Endpoint {
37    /// The dialer-ready transport (tcp+tls; plaintext only for loopback dev).
38    pub(super) transport: Transport,
39    pub(super) http_path: String,
40    pub(super) host_header: String,
41    /// The resolved bearer credential for THIS endpoint (never logged/serialized).
42    pub(super) token: Option<String>,
43    pub(super) provider: Provider,
44    /// Structural transport scheme for the §4.4 resource body (`https`, or
45    /// `http` for the loopback dev carve-out) — never the URL (RFC 0012 §3.7).
46    pub(super) scheme: &'static str,
47    /// Structural address for the §4.4 resource body (`host[:port]`) — the host
48    /// only, no secret, no scheme, no path.
49    pub(super) addr: String,
50    /// Extra request headers (RFC 0031): the resolved `intelligence.headers`,
51    /// pushed on every dial. Empty by default (byte-identical legacy path).
52    pub(super) extra_headers: Vec<(String, String)>,
53    /// An optional per-request signer (RFC 0031): AWS SigV4 over the exact body,
54    /// added on every dial (like `aauth_headers`). `None` = the legacy path.
55    /// Shared across the sticky-primary endpoints of one client.
56    pub(super) signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>>,
57    /// Live health + circuit breaker (RFC 0018 §4).
58    pub health: HealthRecord,
59}
60
61/// The ordered endpoint list with the sticky-primary `active` cursor (§3.3).
62pub struct EndpointList {
63    eps: Vec<Endpoint>,
64    /// The index currently preferred (sticky-primary, §3.3). Plain `usize`
65    /// behind the dialer's `&mut`/single-thread call path — the per-subagent
66    /// `IntelClient` is not shared across threads.
67    active: usize,
68    breaker: BreakerConfig,
69}
70
71/// Resolve an env var (default impl; overridable in tests).
72fn env(name: &str) -> Option<String> {
73    std::env::var(name).ok()
74}
75
76impl EndpointList {
77    /// Parse the comma-list `uri` into an ordered `EndpointList`, resolving each
78    /// endpoint's credential. The single `default_token` is endpoint 1's value
79    /// when the env override is unset (it is the already-resolved
80    /// `--intelligence-token`/`_FILE`, RFC 0006). Per-endpoint env overrides
81    /// (`AGENTD_INTELLIGENCE_TOKEN_<N>` / `_FILE`) win when present.
82    pub fn parse(uri: &str, default_token: Option<String>) -> Result<EndpointList, IntelError> {
83        Self::parse_with_env(uri, default_token, &env)
84    }
85
86    /// `parse` with an injectable env reader (for tests).
87    pub fn parse_with_env(
88        uri: &str,
89        default_token: Option<String>,
90        env: &dyn Fn(&str) -> Option<String>,
91    ) -> Result<EndpointList, IntelError> {
92        let provider = Provider::OpenAiCompatible;
93        let parts: Vec<&str> = uri
94            .split(',')
95            .map(str::trim)
96            .filter(|s| !s.is_empty())
97            .collect();
98        if parts.is_empty() {
99            return Err(IntelError::Unsupported(
100                "empty intelligence endpoint list".into(),
101            ));
102        }
103        let mut eps = Vec::with_capacity(parts.len());
104        for (i, part) in parts.iter().enumerate() {
105            let (transport, http_path, host_header) = resolve(part, provider)?;
106            let token = resolve_token(i, default_token.as_deref(), env)?;
107            let (scheme, addr) = scheme_and_addr(part);
108            eps.push(Endpoint {
109                transport,
110                http_path,
111                host_header,
112                token,
113                provider,
114                scheme,
115                addr,
116                extra_headers: Vec::new(),
117                signer: None,
118                health: HealthRecord::new(),
119            });
120        }
121        Ok(EndpointList {
122            eps,
123            active: 0,
124            breaker: BreakerConfig::default(),
125        })
126    }
127
128    /// Apply extra request headers (RFC 0031 `intelligence.headers`) to every
129    /// endpoint. Cheap; called once after construction.
130    pub fn set_extra_headers(&mut self, headers: Vec<(String, String)>) {
131        for e in &mut self.eps {
132            e.extra_headers = headers.clone();
133        }
134    }
135
136    /// Attach a per-request signer (RFC 0031: AWS SigV4) to every endpoint.
137    pub fn set_signer(&mut self, signer: std::sync::Arc<dyn ::mcp::http::RequestSigner>) {
138        for e in &mut self.eps {
139            e.signer = Some(signer.clone());
140        }
141    }
142
143    /// Select the wire dialect for every endpoint (RFC 0031 §8 —
144    /// `intelligence.dialect`). A host-only endpoint resolved its path with the
145    /// OpenAI default, so re-point a still-defaulted path to the new dialect's
146    /// default (Bedrock overrides the path per-request regardless; an explicit
147    /// non-default path is left untouched).
148    pub fn set_provider(&mut self, provider: Provider) {
149        let default = provider.default_path();
150        for e in &mut self.eps {
151            e.provider = provider;
152            if e.http_path == super::openai::DEFAULT_PATH {
153                e.http_path = default.to_string();
154            }
155        }
156    }
157
158    pub fn len(&self) -> usize {
159        self.eps.len()
160    }
161
162    pub fn is_empty(&self) -> bool {
163        self.eps.is_empty()
164    }
165
166    pub fn active(&self) -> usize {
167        self.active
168    }
169
170    pub fn breaker_config(&self) -> &BreakerConfig {
171        &self.breaker
172    }
173
174    pub fn ep(&self, idx: usize) -> &Endpoint {
175        &self.eps[idx]
176    }
177
178    pub fn iter(&self) -> impl Iterator<Item = &Endpoint> {
179        self.eps.iter()
180    }
181
182    /// The failover attempt order (§3.3): the **active** index first, then the
183    /// remaining endpoints in ascending list order, skipping any whose breaker is
184    /// OPEN-and-cooling (`available` promotes an elapsed-cooldown endpoint to
185    /// HALF-OPEN so it is probed). An empty result == all endpoints down (§6).
186    pub fn attempt_order(&self) -> Vec<usize> {
187        let mut order = Vec::with_capacity(self.eps.len());
188        if self.eps[self.active].health.available(&self.breaker) {
189            order.push(self.active);
190        }
191        for idx in 0..self.eps.len() {
192            if idx == self.active {
193                continue;
194            }
195            if self.eps[idx].health.available(&self.breaker) {
196                order.push(idx);
197            }
198        }
199        order
200    }
201
202    /// Snap `active` back to the lowest-index endpoint whose breaker is not OPEN
203    /// (sticky-primary, §3.3) — so once the primary re-closes, the next call
204    /// returns to it and a fallback is temporary by construction. Returns the new
205    /// active index if it changed.
206    pub fn prefer_lowest_healthy(&mut self) -> Option<usize> {
207        let target = (0..self.eps.len()).find(|&i| self.eps[i].health.is_up());
208        if let Some(t) = target
209            && t != self.active
210        {
211            self.active = t;
212            return Some(t);
213        }
214        None
215    }
216
217    /// Mark `idx` as the active endpoint (it just succeeded). Returns the new
218    /// active index if it changed.
219    pub fn set_active(&mut self, idx: usize) -> Option<usize> {
220        if idx != self.active {
221            self.active = idx;
222            Some(idx)
223        } else {
224            None
225        }
226    }
227
228    /// True when no endpoint is available — every breaker OPEN-and-cooling (§6).
229    pub fn all_down(&self) -> bool {
230        self.attempt_order().is_empty()
231    }
232
233    /// The active endpoint's bounded structural identity `(index, transport-scheme)`
234    /// for the child→supervisor [`crate::subagent::protocol::AgentMsg::IntelHealth`]
235    /// report — transport + index ONLY, NEVER the URL/cid/host or credential (RFC
236    /// 0012 §3.7, mirroring the §4.4 resource-body redaction).
237    pub fn active_identity(&self) -> (usize, &'static str) {
238        (self.active, self.eps[self.active].scheme)
239    }
240
241    /// The `agentd://intelligence` resource body (RFC 0018 §4.4): the endpoint
242    /// list (transport + index, NEVER the URL/creds), which is active, and each
243    /// one's health (state/latency/error-rate). No secret, no URL (RFC 0012
244    /// §3.7) — only the bounded structural `transport`+`addr` (cid:port / host,
245    /// no scheme-borne secret) and the live atomics.
246    pub fn body(&self, model: Option<&str>) -> serde_json::Value {
247        use serde_json::json;
248        let cfg = &self.breaker;
249        let endpoints: Vec<serde_json::Value> = self
250            .eps
251            .iter()
252            .enumerate()
253            .map(|(i, ep)| {
254                let h = &ep.health;
255                let mut e = json!({
256                    "index": i,
257                    "transport": ep.scheme,
258                    "addr": ep.addr,
259                    "state": h.state().as_str(),
260                    "active": i == self.active,
261                    "ewma_latency_ms": h.ewma_latency_ms(),
262                    "error_rate": h.error_rate(),
263                    "consec_fail": h.consec_fail(),
264                });
265                if let serde_json::Value::Object(m) = &mut e {
266                    if let Some(ms) = h.last_ok_ms_ago() {
267                        m.insert("last_ok_ms_ago".into(), json!(ms));
268                    }
269                    if h.state() == super::health::BreakerState::Open {
270                        if let Some(ms) = h.opened_ms_ago() {
271                            m.insert("opened_ms_ago".into(), json!(ms));
272                        }
273                        m.insert(
274                            "cooldown_ms".into(),
275                            json!(h.cooldown(cfg).as_millis() as u64),
276                        );
277                        m.insert("last_err".into(), json!(h.last_err_kind().as_str()));
278                    }
279                }
280                e
281            })
282            .collect();
283        json!({
284            "active": self.active,
285            "all_down": self.all_down(),
286            "model": model,
287            "endpoints": endpoints,
288        })
289    }
290}
291
292/// Resolve endpoint `idx` (0-based)'s credential. The per-endpoint env override
293/// is 1-indexed: endpoint 0 → `AGENTD_INTELLIGENCE_TOKEN` (or the
294/// already-resolved default), endpoint 1 → `AGENTD_INTELLIGENCE_TOKEN_2`, etc.
295/// A `…_FILE` variant is read through the secret-file reader (rotation-friendly,
296/// 0017-A). The override wins over the default; absent ⇒ no token for that
297/// endpoint (a public/unauthenticated gateway is legal).
298fn resolve_token(
299    idx: usize,
300    default_token: Option<&str>,
301    env: &dyn Fn(&str) -> Option<String>,
302) -> Result<Option<String>, IntelError> {
303    // Endpoint 1 (idx 0) uses the bare names; later endpoints are 1-indexed
304    // (`_2`, `_3`, …). Each branded `AGENTD_*` name has a neutral `AGENT_*` alias
305    // (ACC SPEC L4) accepted on input — branded never dropped.
306    let (inline_var, file_var, inline_var_n, file_var_n) = if idx == 0 {
307        (
308            TOKEN_ENV.to_string(),
309            format!("{TOKEN_ENV}_FILE"),
310            TOKEN_ENV_NEUTRAL.to_string(),
311            format!("{TOKEN_ENV_NEUTRAL}_FILE"),
312        )
313    } else {
314        let n = idx + 1;
315        (
316            format!("{TOKEN_ENV}_{n}"),
317            format!("{TOKEN_ENV}_{n}_FILE"),
318            format!("{TOKEN_ENV_NEUTRAL}_{n}"),
319            format!("{TOKEN_ENV_NEUTRAL}_{n}_FILE"),
320        )
321    };
322    // Precedence: explicit inline env override > file override > the resolved
323    // default (only for endpoint 0). Higher-precedence inline wins. At each tier
324    // the neutral `AGENT_*` spelling is read first, then the branded `AGENTD_*`.
325    if let Some(v) = env(&inline_var_n).or_else(|| env(&inline_var)) {
326        return Ok(Some(v));
327    }
328    if let Some(path) = env(&file_var_n).or_else(|| env(&file_var)) {
329        let tok = crate::sec::secret::read_token_file(&path).map_err(IntelError::Unsupported)?;
330        return Ok(Some(tok));
331    }
332    if idx == 0 {
333        return Ok(default_token.map(str::to_string));
334    }
335    Ok(None)
336}
337
338/// The structural `(scheme, addr)` for the §4.4 resource body — the bounded
339/// transport identity only, never the URL path or any secret (RFC 0012 §3.7).
340/// HTTPS-only (pivot Phase 1): `http` appears only for the loopback dev
341/// carve-out; [`resolve`](super::client) already rejected everything else.
342fn scheme_and_addr(uri: &str) -> (&'static str, String) {
343    if let Some(rest) = uri.strip_prefix("https://") {
344        ("https", host_only(rest))
345    } else if let Some(rest) = uri.strip_prefix("http://") {
346        ("http", host_only(rest))
347    } else {
348        ("unknown", String::new())
349    }
350}
351
352/// The host[:port] of an `http(s)://host[:port]/path`, dropping the path (it may
353/// be sensitive and is not addressing).
354fn host_only(rest: &str) -> String {
355    rest.split('/').next().unwrap_or(rest).to_string()
356}
357
358/// A tool name in provider-safe wire form: OpenAI/Anthropic require tool names to
359/// match `^[a-zA-Z0-9_-]+$`, so every other char — notably the `.` in agentd's
360/// namespaced self-tools (`resource.read`, `subagent.spawn`) — becomes `_`. A
361/// per-request reverse map restores the original name for routing.
362fn wire_tool_name(name: &str) -> String {
363    name.chars()
364        .map(|c| {
365            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
366                c
367            } else {
368                '_'
369            }
370        })
371        .collect()
372}
373
374impl Endpoint {
375    /// AAuth (RFC 0023; agentctl RFC 0024 §7.1 — the modelgateway inbound
376    /// posture): when a process AAuth identity is installed, **sign the
377    /// intelligence dial** (RFC 9421) so the gateway can attest the agent by
378    /// signature instead of source IP. Additive and identity-cover only — a
379    /// non-AAuth endpoint ignores the headers, and the bearer (if any) still
380    /// rides alongside. Empty without `--features aauth` or with no identity
381    /// configured, so the default path is byte-identical to before.
382    #[cfg(feature = "aauth")]
383    fn aauth_headers(&self, method: &str, path: &str, body: &[u8]) -> Vec<(String, String)> {
384        match crate::aauth::signer() {
385            Some(signer) => signer.sign(method, &self.host_header, path, body),
386            None => Vec::new(),
387        }
388    }
389    #[cfg(not(feature = "aauth"))]
390    fn aauth_headers(&self, _method: &str, _path: &str, _body: &[u8]) -> Vec<(String, String)> {
391        Vec::new()
392    }
393
394    /// Build the request body + headers for this endpoint's dialect, then dial +
395    /// round-trip exactly as RFC 0006 (`complete_once`). Returns the parsed
396    /// response and the round-trip latency. The wire/adapter/JSON path is
397    /// UNCHANGED (§3.4) — only endpoint *selection* wraps it.
398    pub(super) fn complete_once(
399        &self,
400        req: &crate::wire::intel::Request,
401        timeout: Duration,
402        trace_id: Option<&str>,
403    ) -> Result<(crate::wire::intel::Response, Duration), IntelError> {
404        use super::{anthropic, openai};
405        use crate::net::http;
406        use std::collections::HashMap;
407        use std::time::Instant;
408
409        // Provider tool-name compatibility: real OpenAI/Anthropic reject tool names
410        // that aren't `^[a-zA-Z0-9_-]+$`, but agentd uses dotted namespaced names
411        // (`resource.read`, `subagent.spawn`, …). Sanitize every place a name rides
412        // the wire — the `tools` definitions AND the prior `tool_calls` in the
413        // assistant message history (which get re-sent each turn) — and map the
414        // returned `tool_calls` back to the originals so routing is unaffected.
415        // No-op (no clone) when every name is already wire-safe.
416        use crate::wire::intel::Message;
417        let dirty = |n: &str| wire_tool_name(n) != n;
418        let must_sanitize = req.tools.iter().any(|t| dirty(&t.name))
419            || req.messages.iter().any(|m| {
420                matches!(m, Message::Assistant { tool_calls, .. }
421                    if tool_calls.iter().any(|tc| dirty(&tc.name)))
422            });
423        let mut wire_to_orig: HashMap<String, String> = HashMap::new();
424        let owned_req;
425        let req: &crate::wire::intel::Request = if must_sanitize {
426            let mut r = req.clone();
427            for t in &mut r.tools {
428                let w = wire_tool_name(&t.name);
429                if w != t.name {
430                    wire_to_orig.insert(w.clone(), t.name.clone());
431                    t.name = w;
432                }
433            }
434            for m in &mut r.messages {
435                if let Message::Assistant { tool_calls, .. } = m {
436                    for tc in tool_calls {
437                        let w = wire_tool_name(&tc.name);
438                        if w != tc.name {
439                            wire_to_orig.insert(w.clone(), tc.name.clone());
440                            tc.name = w;
441                        }
442                    }
443                }
444            }
445            owned_req = r;
446            &owned_req
447        } else {
448            req
449        };
450
451        use super::bedrock;
452        let (body, mut headers) = match self.provider {
453            Provider::OpenAiCompatible => openai::build_request(req, self.token.as_deref()),
454            Provider::Anthropic => anthropic::build_request(req, self.token.as_deref()),
455            Provider::Bedrock => bedrock::build_request(req, self.token.as_deref()),
456        };
457        // The effective request path (RFC 0031 §8): fixed for OpenAI/Anthropic;
458        // Bedrock derives `/model/{modelId}/converse` from the request. The SAME
459        // string is sent on the wire AND fed to the AAuth/SigV4 signers below, so
460        // the signature covers the exact request-target.
461        let path = self.provider.request_path(&self.http_path, req);
462        if let Some(tid) = trace_id {
463            headers.push((
464                "traceparent".into(),
465                crate::obs::trace::outbound_traceparent(tid),
466            ));
467        }
468        // RFC 0031: the configured `intelligence.headers` ride every dial (e.g.
469        // a gateway routing header). Pushed before AAuth so a signature covers a
470        // stable header set; a header already set by the dialect is not removed.
471        for (k, v) in &self.extra_headers {
472            headers.push((k.clone(), v.clone()));
473        }
474        // AAuth: sign the dial over the exact body bytes (content-digest cover
475        // applies when discovery flagged it) before we borrow `headers`.
476        for (k, v) in self.aauth_headers("POST", &path, &body) {
477            headers.push((k, v));
478        }
479        // RFC 0031: an AWS SigV4 signature over the exact body, when an `aws`
480        // intelligence auth is configured (native Bedrock, or a Bedrock/API-Gateway
481        // LLM endpoint). Signed over `path` — the dynamic Bedrock target included.
482        if let Some(signer) = &self.signer {
483            for (k, v) in signer.sign("POST", &self.host_header, &path, &body) {
484                headers.push((k, v));
485            }
486        }
487        let header_refs: Vec<(&str, &str)> = headers
488            .iter()
489            .map(|(k, v)| (k.as_str(), v.as_str()))
490            .collect();
491
492        // Same-endpoint transient retry (RFC 0006 §7: every dial is fresh, so a
493        // re-dial is safe). A 429/5xx is often a momentary provider blip; retry it
494        // a bounded number of times with short backoff BEFORE the error escapes to
495        // failover (across endpoints) or — in `once` mode, which arms no
496        // higher-level retry loop — straight to exit 4. A non-transient 4xx (bad
497        // request, auth) is a caller error, identical on a re-dial, so it surfaces
498        // immediately; retrying it would only burn the run deadline. The body +
499        // headers (incl. the AAuth signature over the body) are already built and
500        // unchanged across attempts, so re-dialing re-sends the same bytes.
501        const TRANSIENT_RETRIES: u32 = 2;
502        let mut attempt: u32 = 0;
503        let (resp, latency) = loop {
504            let start = Instant::now();
505            let mut stream = self.transport.connect(timeout)?;
506            let resp = http::send(
507                stream.as_mut(),
508                &self.host_header,
509                "POST",
510                &path,
511                &header_refs,
512                &body,
513            )?;
514            let latency = start.elapsed();
515            if resp.is_success() {
516                break (resp, latency);
517            }
518            if super::failover::is_transient_status(resp.status) && attempt < TRANSIENT_RETRIES {
519                attempt += 1;
520                // Short exponential backoff (250ms, 500ms) — enough to ride out a
521                // blip, bounded so total added wait stays well under a second.
522                std::thread::sleep(Duration::from_millis(250 * (1u64 << (attempt - 1))));
523                continue;
524            }
525            let snippet: String = resp.body_str().chars().take(512).collect();
526            return Err(IntelError::Http(resp.status, snippet));
527        };
528
529        let mut parsed = match self.provider {
530            Provider::OpenAiCompatible => openai::parse_response(&resp.body),
531            Provider::Anthropic => anthropic::parse_response(&resp.body),
532            Provider::Bedrock => bedrock::parse_response(&resp.body),
533        }
534        .map_err(IntelError::Parse)?;
535        // Undo the wire sanitization: route by the original (dotted) tool names.
536        if !wire_to_orig.is_empty() {
537            for tc in &mut parsed.tool_calls {
538                if let Some(orig) = wire_to_orig.get(&tc.name) {
539                    tc.name = orig.clone();
540                }
541            }
542        }
543        Ok((parsed, latency))
544    }
545
546    /// RFC 0018 §5.4 model-discovery probe: one hand-rolled HTTP **GET** to the
547    /// `/v1/models` sibling of this endpoint's chat path, over the SAME transport
548    /// (tcp+tls) + the SAME bearer auth the chat call uses — no new client, no
549    /// streaming. Returns the discovered model `id`s.
550    ///
551    /// **Best-effort, silent-degrade (§5.4):** the `anthropic` dialect has no list
552    /// endpoint → `vec![]`; for OpenAI-compatible, a connection/transport failure,
553    /// a non-2xx (e.g. 404 — discovery unsupported), or a non-JSON/unexpected body
554    /// all yield `vec![]`. NEVER a failover-class error, NEVER fatal — the endpoint
555    /// is fully usable with discovery unsupported (the configured model is dialed
556    /// regardless). The caller bounds it with a SHORT timeout (off the hot path).
557    pub(super) fn discover_models(&self, timeout: Duration) -> Vec<String> {
558        use super::openai;
559        use crate::net::http;
560
561        // Dialect detection is already known from the provider (§5.4 — reuse, don't
562        // re-detect). Anthropic has no list endpoint.
563        if self.provider != Provider::OpenAiCompatible {
564            return Vec::new();
565        }
566
567        let path = openai::models_path(&self.http_path);
568        // Same auth header the chat call sends (`Authorization: Bearer …`), no body.
569        let mut headers: Vec<(String, String)> = Vec::new();
570        if let Some(tok) = self.token.as_deref() {
571            headers.push(("authorization".into(), format!("Bearer {tok}")));
572        }
573        // Sign the discovery GET too (over its own `/v1/models` path), so a
574        // signature-attesting gateway accepts it exactly like the chat dial.
575        for (k, v) in self.aauth_headers("GET", &path, &[]) {
576            headers.push((k, v));
577        }
578        let header_refs: Vec<(&str, &str)> = headers
579            .iter()
580            .map(|(k, v)| (k.as_str(), v.as_str()))
581            .collect();
582
583        // Connect → GET → parse. Any error degrades to [] (silent, never fatal).
584        let Ok(mut stream) = self.transport.connect(timeout) else {
585            return Vec::new();
586        };
587        let Ok(resp) = http::send(
588            stream.as_mut(),
589            &self.host_header,
590            "GET",
591            &path,
592            &header_refs,
593            &[],
594        ) else {
595            return Vec::new();
596        };
597        if !resp.is_success() {
598            // 404 / 4xx / 5xx → discovery unsupported for this endpoint.
599            return Vec::new();
600        }
601        openai::parse_models(&resp.body)
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn extra_headers_apply_to_every_endpoint() {
611        // RFC 0031: `intelligence.headers` (and a device-login bearer) reach the
612        // wire via `set_extra_headers`, applied to all failover endpoints.
613        let mut list = EndpointList::parse(
614            "https://a.example/v1,https://b.example/v1",
615            Some("tok".into()),
616        )
617        .unwrap();
618        assert!(list.eps.iter().all(|e| e.extra_headers.is_empty()));
619        list.set_extra_headers(vec![("X-Team".into(), "ops".into())]);
620        for e in &list.eps {
621            assert_eq!(
622                e.extra_headers,
623                vec![("X-Team".to_string(), "ops".to_string())]
624            );
625        }
626    }
627
628    #[test]
629    fn wire_tool_name_maps_only_illegal_chars() {
630        // The provider pattern is ^[a-zA-Z0-9_-]+$: dots (agentd's namespace
631        // separator) and anything else become `_`; legal names pass through.
632        assert_eq!(wire_tool_name("resource.read"), "resource_read");
633        assert_eq!(wire_tool_name("subagent.spawn"), "subagent_spawn");
634        assert_eq!(wire_tool_name("math.factorial"), "math_factorial");
635        // Already-legal names are untouched (so the fast path stays a no-op).
636        assert_eq!(wire_tool_name("get_weather"), "get_weather");
637        assert_eq!(wire_tool_name("list-files"), "list-files");
638        assert_eq!(
639            wire_tool_name("calculate_triangle_area"),
640            "calculate_triangle_area"
641        );
642        // Other illegal chars (spaces, slashes) also normalize.
643        assert_eq!(wire_tool_name("a b/c"), "a_b_c");
644    }
645
646    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
647        move |k: &str| {
648            pairs
649                .iter()
650                .find(|(n, _)| *n == k)
651                .map(|(_, v)| (*v).to_string())
652        }
653    }
654
655    #[test]
656    fn comma_list_parses_to_n_endpoints_in_order() {
657        let env = env_of(&[]);
658        let list = EndpointList::parse_with_env(
659            "https://gw-a.example:8443,https://gw-b.example:8444,https://intel.example",
660            None,
661            &env,
662        )
663        .unwrap();
664        assert_eq!(list.len(), 3);
665        assert_eq!(list.ep(0).scheme, "https");
666        assert_eq!(list.ep(0).addr, "gw-a.example:8443");
667        assert_eq!(list.ep(1).addr, "gw-b.example:8444");
668        assert_eq!(list.ep(2).scheme, "https");
669        assert_eq!(list.active(), 0);
670    }
671
672    #[test]
673    fn whitespace_around_elements_is_trimmed() {
674        let env = env_of(&[]);
675        let list =
676            EndpointList::parse_with_env(" https://a.example , https://b.example ", None, &env)
677                .unwrap();
678        assert_eq!(list.len(), 2);
679        assert_eq!(list.ep(0).addr, "a.example");
680        assert_eq!(list.ep(1).addr, "b.example");
681    }
682
683    #[test]
684    fn empty_list_is_an_error() {
685        let env = env_of(&[]);
686        assert!(EndpointList::parse_with_env("", None, &env).is_err());
687        assert!(EndpointList::parse_with_env("   ,  ,", None, &env).is_err());
688    }
689
690    #[test]
691    fn bad_element_scheme_is_an_error() {
692        let env = env_of(&[]);
693        let r = EndpointList::parse_with_env("https://a.example,ftp://nope", None, &env);
694        assert!(matches!(r, Err(IntelError::Unsupported(_))));
695        // The retired transports are rejected at the same chokepoint.
696        for uri in ["unix:/a", "vsock:3:8080", "http://not-loopback.example"] {
697            let r = EndpointList::parse_with_env(uri, None, &env);
698            assert!(matches!(r, Err(IntelError::Unsupported(_))), "{uri}");
699        }
700    }
701
702    #[test]
703    fn per_endpoint_token_env_resolves_by_position() {
704        // endpoint 1 uses the bare name (or the default); endpoint 2 uses `_2`.
705        let env = env_of(&[
706            ("AGENTD_INTELLIGENCE_TOKEN", "tok-a"),
707            ("AGENTD_INTELLIGENCE_TOKEN_2", "tok-b"),
708        ]);
709        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
710            .unwrap();
711        assert_eq!(list.ep(0).token.as_deref(), Some("tok-a"));
712        assert_eq!(list.ep(1).token.as_deref(), Some("tok-b"));
713    }
714
715    #[test]
716    fn endpoint_0_falls_back_to_default_token_when_env_unset() {
717        let env = env_of(&[]);
718        let list = EndpointList::parse_with_env(
719            "https://a.example,https://b.example",
720            Some("default".into()),
721            &env,
722        )
723        .unwrap();
724        // endpoint 0 inherits the resolved default; endpoint 1 has none.
725        assert_eq!(list.ep(0).token.as_deref(), Some("default"));
726        assert_eq!(list.ep(1).token, None);
727    }
728
729    #[test]
730    fn per_endpoint_env_override_wins_over_default() {
731        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "from-env")]);
732        let list = EndpointList::parse_with_env("https://a.example", Some("default".into()), &env)
733            .unwrap();
734        assert_eq!(list.ep(0).token.as_deref(), Some("from-env"));
735    }
736
737    #[test]
738    fn neutral_token_env_is_accepted_as_an_alias() {
739        // ACC SPEC L4: the neutral `AGENT_INTELLIGENCE_TOKEN[_N]` spelling is
740        // accepted on input (endpoint 1 bare; later endpoints 1-indexed).
741        let env = env_of(&[
742            ("AGENT_INTELLIGENCE_TOKEN", "neutral-a"),
743            ("AGENT_INTELLIGENCE_TOKEN_2", "neutral-b"),
744        ]);
745        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
746            .unwrap();
747        assert_eq!(list.ep(0).token.as_deref(), Some("neutral-a"));
748        assert_eq!(list.ep(1).token.as_deref(), Some("neutral-b"));
749    }
750
751    #[test]
752    fn branded_token_env_wins_over_neutral_on_conflict() {
753        // Both spellings set ⇒ neutral-first is read; the branded form is still
754        // accepted when the neutral one is absent (alias, never dropped).
755        let env = env_of(&[
756            ("AGENT_INTELLIGENCE_TOKEN", "neutral"),
757            ("AGENTD_INTELLIGENCE_TOKEN", "branded"),
758        ]);
759        let list = EndpointList::parse_with_env("https://a.example", None, &env).unwrap();
760        assert_eq!(list.ep(0).token.as_deref(), Some("neutral"));
761
762        // Branded-only still resolves (back-compat).
763        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "branded")]);
764        let list = EndpointList::parse_with_env("https://a.example", None, &env).unwrap();
765        assert_eq!(list.ep(0).token.as_deref(), Some("branded"));
766    }
767
768    #[test]
769    fn token_file_variant_reads_from_disk() {
770        use std::io::Write;
771        let mut f = tempfile::NamedTempFile::new().unwrap();
772        writeln!(f, "file-secret").unwrap();
773        let path = f.path().to_str().unwrap().to_string();
774        let pairs = [("AGENTD_INTELLIGENCE_TOKEN_2_FILE", path.as_str())];
775        let env = env_of(&pairs);
776        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
777            .unwrap();
778        assert_eq!(list.ep(1).token.as_deref(), Some("file-secret"));
779    }
780
781    #[test]
782    fn single_element_list_is_rfc_0006() {
783        let env = env_of(&[]);
784        let list = EndpointList::parse_with_env("https://intel.example", None, &env).unwrap();
785        assert_eq!(list.len(), 1);
786        // the failover machinery is inert: attempt order is just [0].
787        assert_eq!(list.attempt_order(), vec![0]);
788        assert!(!list.all_down());
789    }
790
791    #[test]
792    fn attempt_order_skips_open_endpoint_and_snaps_back() {
793        use super::super::health::ErrKind;
794        let env = env_of(&[]);
795        let mut list =
796            EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
797                .unwrap();
798        let cfg = *list.breaker_config();
799        // open endpoint 0's breaker (threshold 3)
800        for _ in 0..3 {
801            list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
802        }
803        // attempt order now skips 0, yields [1]
804        assert_eq!(list.attempt_order(), vec![1]);
805        // and 1 is the lowest healthy → prefer_lowest_healthy moves active there
806        assert_eq!(list.prefer_lowest_healthy(), Some(1));
807        assert_eq!(list.active(), 1);
808        // endpoint 0 recovers → snap back to it
809        list.ep(0).health.record_success(Duration::from_millis(5));
810        assert_eq!(list.prefer_lowest_healthy(), Some(0));
811        assert_eq!(list.active(), 0);
812    }
813
814    #[test]
815    fn resource_body_has_health_and_no_url_or_token() {
816        use super::super::health::ErrKind;
817        let env = env_of(&[("AGENTD_INTELLIGENCE_TOKEN", "super-secret-tok")]);
818        let list = EndpointList::parse_with_env(
819            "https://gw-a.example:8443,https://gw-b.example/v1/secret-path",
820            None,
821            &env,
822        )
823        .unwrap();
824        // make endpoint 1 broken, endpoint 0 healthy + active
825        list.ep(0).health.record_success(Duration::from_millis(41));
826        let cfg = *list.breaker_config();
827        for _ in 0..3 {
828            list.ep(1).health.record_failure(ErrKind::Refused, &cfg);
829        }
830        let body = list.body(Some("claude-opus-4"));
831        let text = body.to_string();
832        // schema: active/all_down/model/endpoints[]
833        assert_eq!(body["active"], 0);
834        assert_eq!(body["model"], "claude-opus-4");
835        assert_eq!(body["endpoints"][0]["transport"], "https");
836        assert_eq!(body["endpoints"][0]["addr"], "gw-a.example:8443");
837        assert_eq!(body["endpoints"][0]["state"], "closed");
838        assert_eq!(body["endpoints"][0]["active"], true);
839        assert_eq!(body["endpoints"][0]["ewma_latency_ms"], 41);
840        assert_eq!(body["endpoints"][1]["state"], "open");
841        assert_eq!(body["endpoints"][1]["last_err"], "refused");
842        // RFC 0012 §3.7: NEVER the token, NEVER a full URL (scheme prefix or path)
843        assert!(!text.contains("super-secret-tok"), "token leaked: {text}");
844        assert!(!text.contains("https://"), "full URI leaked: {text}");
845        assert!(!text.contains("secret-path"), "URL path leaked: {text}");
846    }
847
848    #[test]
849    fn all_down_when_every_breaker_open() {
850        use super::super::health::ErrKind;
851        let env = env_of(&[]);
852        let list = EndpointList::parse_with_env("https://a.example,https://b.example", None, &env)
853            .unwrap();
854        let cfg = *list.breaker_config();
855        for ep in list.iter() {
856            for _ in 0..3 {
857                ep.health.record_failure(ErrKind::Refused, &cfg);
858            }
859        }
860        assert!(list.all_down());
861        assert!(list.attempt_order().is_empty());
862    }
863}