From 519a056513ab770d8b6ea4f2491fc5460ad03eff Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 12:04:22 -0400 Subject: [PATCH 1/9] feat(agent): add an intercept listener for first-party Claude Desktop Add crates/agent/src/intercept, a loopback HTTP CONNECT proxy for Claude Desktop in first-party mode. It is not wired into the daemon yet. - For the intercepted hosts, the listener terminates TLS with a leaf from an intercept CA and serves HTTP/2 and HTTP/1.1 by ALPN. It forwards each request to a gateway with the original host, the user's Cookie and Authorization unchanged, and one x-agentdesktop-identity header. It replaces any identity header that the client sends. - Every other host is tunnelled without decryption. - The intercept CA has Name Constraints for the intercepted hosts and excludes all IP ranges. Leaves are minted only for those hosts. - The listener refuses a request whose host does not match the CONNECT host or the TLS server name (421 or a failed handshake), an intercepted host on a port other than 443, and requests that are not CONNECT. - Bodies, server-sent events and WebSocket upgrades stream in both directions. Upgrades use HTTP/1.1 to the gateway. There is no 401 retry, and tunnels do not close when a credential expires. - Fail closed: when the gateway cannot be reached the request gets a 502. Nothing connects to an intercepted host directly. - Every connection resolves its host through the system resolver, so a DNS change after a network change applies at once. Pooled gateway connections are dropped after a wall-clock idle gap, which also covers sleep. A request without a body that fails before a response is sent once more. - Health counts forwarded requests, failures and the last failure with its cause, for the daemon status. The walkthrough test runs the listener end to end on loopback with no admin rights: cargo test -p agentdesktop-agent intercept::walkthrough -- --nocapture --- Cargo.lock | 3 + Cargo.toml | 2 + crates/agent/Cargo.toml | 7 +- crates/agent/src/intercept/ca.rs | 194 +++++ crates/agent/src/intercept/forward.rs | 619 ++++++++++++++++ crates/agent/src/intercept/listener.rs | 304 ++++++++ crates/agent/src/intercept/mod.rs | 174 +++++ crates/agent/src/intercept/walkthrough.rs | 842 ++++++++++++++++++++++ crates/agent/src/lib.rs | 1 + 9 files changed, 2144 insertions(+), 2 deletions(-) create mode 100644 crates/agent/src/intercept/ca.rs create mode 100644 crates/agent/src/intercept/forward.rs create mode 100644 crates/agent/src/intercept/listener.rs create mode 100644 crates/agent/src/intercept/mod.rs create mode 100644 crates/agent/src/intercept/walkthrough.rs diff --git a/Cargo.lock b/Cargo.lock index 78e8f3f..2c9c65c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,10 +83,13 @@ dependencies = [ "similar", "tempfile", "testcontainers", + "time", "tokio", + "tokio-rustls", "tokio-stream", "toml 1.1.6+spec-1.1.0", "tonic", + "tower-service", "tracing", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index 3657699..9f134b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,10 +63,12 @@ sha2 = "0.11" similar = "3.2" sqlx = { version = "0.9", default-features = false, features = ["any", "macros", "migrate", "runtime-tokio", "sqlite"] } tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } tokio-stream = "0.1" tonic = { version = "0.14", features = ["transport", "tls-aws-lc"] } tonic-prost = "0.14" tonic-prost-build = "0.14" +tower-service = "0.3" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 695324a..6c1f3b7 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -31,9 +31,9 @@ base64.workspace = true bytes.workspace = true clap.workspace = true hostname.workspace = true -hyper.workspace = true +hyper = { workspace = true, features = ["http2"] } hyper-rustls.workspace = true -hyper-util = { workspace = true, features = ["client-legacy", "http1"] } +hyper-util = { workspace = true, features = ["client-legacy", "http1", "http2", "server-auto"] } http-body-util.workspace = true json5.workspace = true jsonc-parser.workspace = true @@ -50,9 +50,12 @@ serde_json.workspace = true serde_yaml.workspace = true sha2.workspace = true similar.workspace = true +time.workspace = true tokio.workspace = true +tokio-rustls.workspace = true tokio-stream.workspace = true tonic.workspace = true +tower-service.workspace = true tracing.workspace = true toml.workspace = true url.workspace = true diff --git a/crates/agent/src/intercept/ca.rs b/crates/agent/src/intercept/ca.rs new file mode 100644 index 0000000..732b63d --- /dev/null +++ b/crates/agent/src/intercept/ca.rs @@ -0,0 +1,194 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, SystemTime}, +}; + +use anyhow::Context; +use rcgen::{ + BasicConstraints, CertificateParams, CidrSubnet, DistinguishedName, DnType, + ExtendedKeyUsagePurpose, GeneralSubtree, IsCa, Issuer, KeyPair, KeyUsagePurpose, + NameConstraints, +}; +use rustls::{ + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + sign::CertifiedKey, +}; +use time::OffsetDateTime; + +/// Validity of a generated intercept CA. +const CA_VALIDITY: Duration = Duration::from_secs(2 * 365 * 24 * 60 * 60); +/// Validity of a leaf. Leaves live only in memory and are minted again before they expire. +const LEAF_VALIDITY: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// A cached leaf is replaced once less than this much of its validity remains. +const LEAF_RENEW_BEFORE: Duration = Duration::from_secs(24 * 60 * 60); +/// Certificates start this far in the past, to allow for a client clock that runs behind. +const BACKDATE: Duration = Duration::from_secs(60 * 60); + +/// The CA that the intercept listener mints its TLS leaves from. +/// +/// The CA certificate carries X.509 Name Constraints: it may only issue for the +/// intercepted hosts (and their subdomains), and never for an IP address. A +/// verifier that enforces constraints on trust anchors (rustls-webpki, +/// OpenSSL, Chromium's verifier) then rejects any other leaf signed with this +/// key, even if the key leaks. Not every verifier enforces them, so the +/// listener also refuses to mint for any other host. +/// +/// This is not the controller's device CA (`device_ca.rs`), which signs the +/// daemon's mTLS client certificates. +pub struct InterceptCa { + issuer: Issuer<'static, KeyPair>, + certificate_der: CertificateDer<'static>, + certificate_pem: String, + hosts: Vec, + leaves: Mutex>, +} + +struct Leaf { + key: Arc, + not_after: SystemTime, +} + +impl std::fmt::Debug for InterceptCa { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InterceptCa") + .field("hosts", &self.hosts) + .finish_non_exhaustive() + } +} + +impl InterceptCa { + /// Generate a CA that may issue only for `hosts`. Each host must be a DNS + /// name in lower case; a constraint on a name also covers its subdomains. + pub fn generate(common_name: &str, hosts: &[String]) -> anyhow::Result { + if hosts.is_empty() { + anyhow::bail!("an intercept CA needs at least one host"); + } + let key = KeyPair::generate().context("generate the intercept CA key")?; + let mut params = CertificateParams::default(); + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, common_name); + params.distinguished_name = name; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.name_constraints = Some(NameConstraints { + permitted_subtrees: hosts + .iter() + .map(|host| GeneralSubtree::DnsName(host.clone())) + .collect(), + // DNS-only constraints leave IP-address names open, so all of them + // are excluded. + excluded_subtrees: vec![ + GeneralSubtree::IpAddress(CidrSubnet::from_v4_prefix([0; 4], 0)), + GeneralSubtree::IpAddress(CidrSubnet::from_v6_prefix([0; 16], 0)), + ], + }); + let now = SystemTime::now(); + params.not_before = OffsetDateTime::from(now - BACKDATE); + params.not_after = OffsetDateTime::from(now + CA_VALIDITY); + let certificate = params + .self_signed(&key) + .context("self-sign the intercept CA")?; + Ok(Self { + certificate_der: certificate.der().clone(), + certificate_pem: certificate.pem(), + issuer: Issuer::new(params, key), + hosts: hosts.to_vec(), + leaves: Mutex::default(), + }) + } + + /// The CA certificate, for a trust store or an MDM profile. + pub fn certificate_pem(&self) -> &str { + &self.certificate_pem + } + + pub fn certificate_der(&self) -> &CertificateDer<'static> { + &self.certificate_der + } + + /// Whether this CA may issue for `host`. + pub fn covers(&self, host: &str) -> bool { + self.hosts.iter().any(|allowed| allowed == host) + } + + /// A leaf for `host` with the CA in its chain, from the cache when it is + /// not close to expiry. Only hosts the CA was generated for are minted. + pub(super) fn leaf(&self, host: &str) -> anyhow::Result> { + if !self.covers(host) { + anyhow::bail!("{host} is not an intercepted host"); + } + let now = SystemTime::now(); + let mut leaves = self.leaves.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(leaf) = leaves.get(host) + && leaf.not_after > now + LEAF_RENEW_BEFORE + { + return Ok(leaf.key.clone()); + } + let leaf = self.mint(host, now)?; + let key = leaf.key.clone(); + leaves.insert(host.to_owned(), leaf); + Ok(key) + } + + /// A leaf for any name, without the host check: the walkthrough test uses + /// it to show that a verifier rejects names outside the constraints. + #[cfg(test)] + pub(super) fn leaf_unchecked(&self, host: &str) -> anyhow::Result> { + Ok(self.mint(host, SystemTime::now())?.key) + } + + fn mint(&self, host: &str, now: SystemTime) -> anyhow::Result { + let key = KeyPair::generate().context("generate a leaf key")?; + let mut params = CertificateParams::new(vec![host.to_owned()]) + .with_context(|| format!("leaf parameters for {host}"))?; + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, host); + params.distinguished_name = name; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let not_after = now + LEAF_VALIDITY; + params.not_before = OffsetDateTime::from(now - BACKDATE); + params.not_after = OffsetDateTime::from(not_after); + let certificate = params + .signed_by(&key, &self.issuer) + .with_context(|| format!("sign a leaf for {host}"))?; + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); + let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&private_key) + .context("load the leaf key")?; + Ok(Leaf { + key: Arc::new(CertifiedKey::new( + vec![certificate.der().clone(), self.certificate_der.clone()], + signing_key, + )), + not_after, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hosts() -> Vec { + vec!["claude.ai".to_owned(), "api.anthropic.com".to_owned()] + } + + #[test] + fn mints_only_for_its_hosts_and_reuses_the_cached_leaf() { + let ca = InterceptCa::generate("test intercept CA", &hosts()).unwrap(); + let first = ca.leaf("claude.ai").unwrap(); + let second = ca.leaf("claude.ai").unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(first.cert.len(), 2, "leaf and CA"); + assert_eq!(&first.cert[1], ca.certificate_der()); + assert!(ca.leaf("example.com").is_err()); + assert!(ca.leaf("claude.ai.example.com").is_err()); + } + + #[test] + fn an_empty_host_list_is_refused() { + assert!(InterceptCa::generate("test intercept CA", &[]).is_err()); + } +} diff --git a/crates/agent/src/intercept/forward.rs b/crates/agent/src/intercept/forward.rs new file mode 100644 index 0000000..014a63e --- /dev/null +++ b/crates/agent/src/intercept/forward.rs @@ -0,0 +1,619 @@ +use std::{ + future::Future, + io, + pin::Pin, + sync::{Arc, Mutex, PoisonError}, + task::{Context, Poll}, + time::{Duration, SystemTime}, +}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty, combinators::BoxBody}; +use hyper::{ + HeaderMap, Method, Request, Response, StatusCode, Uri, Version, + body::{Body as _, Incoming}, + header::{CONNECTION, HOST, HeaderValue}, +}; +use hyper_util::{ + client::legacy::{ + Client, + connect::{Connected, Connection}, + }, + rt::{TokioExecutor, TokioIo, TokioTimer}, +}; +use rustls::{ClientConfig, RootCertStore, pki_types::ServerName}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::TcpStream, +}; + +use super::{Health, PendingTunnel, connect_tcp, text_response}; + +/// The header that carries the gateway credential. The listener removes any +/// copy the client sends and adds its own; the gateway validates it and +/// removes it before the request goes upstream. +pub const IDENTITY_HEADER: &str = "x-agentdesktop-identity"; + +/// How long to wait for the gateway's response headers. Streaming bodies have +/// no total timeout. +const RESPONSE_HEADERS_TIMEOUT: Duration = Duration::from_secs(600); +/// Pooled gateway connections idle for longer than this are not reused. +const DEFAULT_IDLE_RESET: Duration = Duration::from_secs(60); + +pub(super) type Body = BoxBody; + +/// The gateway that decrypted requests are forwarded to. +/// +/// Every request keeps its original host (`Host` on HTTP/1.1, `:authority` on +/// HTTP/2), so one gateway listener can route claude.ai and api.anthropic.com +/// by host. The connection itself always goes to the gateway's address. +#[derive(Clone)] +pub struct GatewayTarget { + host: String, + port: u16, + /// TLS to the gateway: ALPN `h2` and `http/1.1`, and `http/1.1` only for + /// WebSocket upgrades (an upgrade cannot cross an HTTP/2 connection). + tls: Option<(Arc, Arc)>, +} + +impl std::fmt::Debug for GatewayTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayTarget") + .field("host", &self.host) + .field("port", &self.port) + .field("tls", &self.tls.is_some()) + .finish() + } +} + +impl GatewayTarget { + /// A plain HTTP/1.1 gateway, for a gateway on the same host. + pub fn http(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + tls: None, + } + } + + /// A gateway over TLS, verified against `roots`. + pub fn https(host: impl Into, port: u16, roots: RootCertStore) -> anyhow::Result { + let roots = Arc::new(roots); + let config = |alpn: &[&[u8]]| -> anyhow::Result> { + let mut config = ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots.clone()) + .with_no_client_auth(); + config.alpn_protocols = alpn.iter().map(|proto| proto.to_vec()).collect(); + Ok(Arc::new(config)) + }; + Ok(Self { + host: host.into(), + port, + tls: Some((config(&[b"h2", b"http/1.1"])?, config(&[b"http/1.1"])?)), + }) + } +} + +/// Where the identity header's value comes from. +#[async_trait::async_trait] +pub trait CredentialSource: Send + Sync { + /// The current gateway credential, or `None` when the gateway has no + /// authentication configured. An error fails the request: it is never + /// forwarded without the identity it should carry. + async fn credential(&self) -> Result, String>; +} + +/// Opens gateway connections. The request URI names the original host and is +/// ignored here: every connection goes to the gateway. +#[derive(Clone)] +struct GatewayConnector { + target: Arc, + /// Offer HTTP/2 when the gateway uses TLS. + allow_h2: bool, +} + +impl tower_service::Service for GatewayConnector { + type Response = GatewayIo; + type Error = io::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _uri: Uri) -> Self::Future { + let target = self.target.clone(); + let allow_h2 = self.allow_h2; + Box::pin(async move { + let tcp = connect_tcp(&target.host, target.port).await?; + let Some((with_h2, http1_only)) = &target.tls else { + return Ok(GatewayIo { + io: TokioIo::new(GatewayStream::Plain(tcp)), + h2: false, + }); + }; + let config = if allow_h2 { with_h2 } else { http1_only }; + let name = ServerName::try_from(target.host.clone()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let tls = tokio_rustls::TlsConnector::from(config.clone()) + .connect(name, tcp) + .await?; + let h2 = tls.get_ref().1.alpn_protocol() == Some(b"h2".as_slice()); + Ok(GatewayIo { + io: TokioIo::new(GatewayStream::Tls(Box::new(tls))), + h2, + }) + }) + } +} + +enum GatewayStream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for GatewayStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + Self::Tls(stream) => Pin::new(stream.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for GatewayStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + Self::Tls(stream) => Pin::new(stream.as_mut()).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(stream) => Pin::new(stream).poll_flush(cx), + Self::Tls(stream) => Pin::new(stream.as_mut()).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(stream) => Pin::new(stream).poll_shutdown(cx), + Self::Tls(stream) => Pin::new(stream.as_mut()).poll_shutdown(cx), + } + } +} + +/// A gateway connection, telling the client whether TLS negotiated HTTP/2. +struct GatewayIo { + io: TokioIo, + h2: bool, +} + +impl Connection for GatewayIo { + fn connected(&self) -> Connected { + if self.h2 { + Connected::new().negotiated_h2() + } else { + Connected::new() + } + } +} + +impl hyper::rt::Read for GatewayIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: hyper::rt::ReadBufCursor<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_read(cx, buf) + } +} + +impl hyper::rt::Write for GatewayIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_shutdown(cx) + } +} + +type GatewayClient = Client; + +struct Clients { + /// Normal requests: HTTP/2 when the gateway negotiates it. + main: GatewayClient, + /// WebSocket upgrades: HTTP/1.1 only. + upgrade: GatewayClient, + /// Wall-clock time of the last request. + last_used: SystemTime, +} + +impl Clients { + fn new(target: &Arc, idle_reset: Duration) -> Self { + let build = |allow_h2| { + // retry_canceled_requests (on by default) resends a request that + // failed on a reused connection before any of it was written. + Client::builder(TokioExecutor::new()) + .pool_timer(TokioTimer::new()) + .pool_idle_timeout(idle_reset) + .retry_canceled_requests(true) + .build(GatewayConnector { + target: target.clone(), + allow_h2, + }) + }; + Self { + main: build(true), + upgrade: build(false), + last_used: SystemTime::now(), + } + } +} + +/// Whether the pooled gateway connections should be dropped before the next +/// request. The pool's idle timer runs on the monotonic clock, which stops +/// while the Mac sleeps, so a connection that died during sleep can look +/// fresh. The wall clock keeps running, so a gap measured on it catches both a +/// long idle period and a sleep. +pub(super) fn needs_fresh_pool( + last_used: SystemTime, + now: SystemTime, + idle_reset: Duration, +) -> bool { + match now.duration_since(last_used) { + Ok(gap) => gap > idle_reset, + // The clock went backwards: start clean. + Err(_) => true, + } +} + +/// Whether a request that failed before a response may be sent once more. Only +/// requests with no body and no side effects qualify; a streamed body cannot +/// be sent twice. +pub(super) fn may_retry(method: &Method, body_is_empty: bool) -> bool { + body_is_empty && matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) +} + +/// Sends decrypted requests to the gateway. +pub(super) struct Forwarder { + target: Arc, + credentials: Arc, + idle_reset: Duration, + clients: Mutex, +} + +impl Forwarder { + pub(super) fn new( + target: GatewayTarget, + credentials: Arc, + idle_reset: Option, + ) -> Self { + let target = Arc::new(target); + let idle_reset = idle_reset.unwrap_or(DEFAULT_IDLE_RESET); + Self { + clients: Mutex::new(Clients::new(&target, idle_reset)), + target, + credentials, + idle_reset, + } + } + + fn clients(&self) -> (GatewayClient, GatewayClient) { + let now = SystemTime::now(); + let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner); + if needs_fresh_pool(clients.last_used, now, self.idle_reset) { + tracing::debug!("intercept: dropping pooled gateway connections after an idle gap"); + *clients = Clients::new(&self.target, self.idle_reset); + } + clients.last_used = now; + (clients.main.clone(), clients.upgrade.clone()) + } + + /// Forward one request that arrived on a connection decrypted for `host`. + pub(super) async fn forward( + &self, + mut request: Request, + host: &str, + health: &Health, + tunnel_slot: &PendingTunnel, + ) -> Response { + if request.method() == Method::CONNECT { + return text_response( + StatusCode::METHOD_NOT_ALLOWED, + "agentdesktop intercept: CONNECT is not allowed inside an intercepted connection", + ); + } + // The host of every request must be the host the connection was + // decrypted for. HTTP/2 can carry several hosts on one connection, and + // a local process could otherwise reach another host with this + // device's identity. + if !request_host_matches(&request, host) { + return text_response( + StatusCode::MISDIRECTED_REQUEST, + "agentdesktop intercept: the request host does not match the connection", + ); + } + let websocket = + request.version() == Version::HTTP_11 && is_websocket_upgrade(request.headers()); + let inbound_upgrade = websocket.then(|| hyper::upgrade::on(&mut request)); + + let credential = match self.credentials.credential().await { + Ok(credential) => credential, + Err(error) => { + tracing::warn!(host, error, "intercept: no gateway credential"); + health.record_gateway_failure(format!("gateway credential: {error}")); + return text_response( + StatusCode::BAD_GATEWAY, + "agentdesktop intercept: could not get the gateway credential", + ); + } + }; + let identity = match credential + .map(|credential| HeaderValue::from_str(&format!("Bearer {credential}"))) + { + None => None, + Some(Ok(mut value)) => { + value.set_sensitive(true); + Some(value) + } + Some(Err(_)) => { + health.record_gateway_failure( + "gateway credential is not a valid header value".to_owned(), + ); + return text_response( + StatusCode::BAD_GATEWAY, + "agentdesktop intercept: the gateway credential is not a valid header value", + ); + } + }; + + let (parts, body) = request.into_parts(); + let path = parts.uri.path_and_query().map_or("/", |path| path.as_str()); + let Ok(uri) = format!("https://{host}{path}").parse::() else { + return text_response( + StatusCode::BAD_REQUEST, + "agentdesktop intercept: invalid request path", + ); + }; + let mut headers = parts.headers; + strip_hop_headers(&mut headers); + if websocket { + headers.insert(CONNECTION, HeaderValue::from_static("upgrade")); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + } + // The client sets the host from the URI: `Host` on HTTP/1.1, + // `:authority` on HTTP/2. + headers.remove(HOST); + headers.remove(IDENTITY_HEADER); + if let Some(identity) = identity { + headers.insert(IDENTITY_HEADER, identity); + } + let body_is_empty = body.is_end_stream(); + let method = parts.method; + let build = |body: Body| { + let mut outbound = Request::new(body); + *outbound.method_mut() = method.clone(); + *outbound.uri_mut() = uri.clone(); + // The gateway connection decides the protocol; hyper sends an + // HTTP/1.1 request as HTTP/2 when the connection negotiated it. + *outbound.version_mut() = Version::HTTP_11; + *outbound.headers_mut() = headers.clone(); + outbound + }; + + let (main, upgrade) = self.clients(); + let client = if websocket { upgrade } else { main }; + let first_body = if websocket { + empty_body() + } else { + body.boxed() + }; + let mut outcome = send(&client, build(first_body)).await; + if outcome.is_err() && may_retry(&method, body_is_empty) && !websocket { + tracing::debug!( + host, + "intercept: retrying a request that failed before any response" + ); + outcome = send(&self.clients().0, build(empty_body())).await; + } + let mut response = match outcome { + Ok(response) => response, + Err(message) => { + tracing::warn!(host, error = %message, "intercept: gateway request failed"); + health.record_gateway_failure(message); + return text_response( + StatusCode::BAD_GATEWAY, + "agentdesktop intercept: gateway unreachable", + ); + } + }; + health.record_forwarded(); + + if let Some(inbound_upgrade) = inbound_upgrade + && response.status() == StatusCode::SWITCHING_PROTOCOLS + { + let upgrade_value = response.headers().get("upgrade").cloned(); + strip_hop_headers(response.headers_mut()); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("upgrade")); + if let Some(value) = upgrade_value { + response.headers_mut().insert("upgrade", value); + } + let outbound_upgrade = hyper::upgrade::on(&mut response); + // Unlike llm_proxy's tunnel, this one is not tied to a credential + // lifetime: Remote Control and Code-tab sockets stay open for a + // long time. + *tunnel_slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::pin( + async move { + match tokio::try_join!(inbound_upgrade, outbound_upgrade) { + Ok((inbound, outbound)) => { + let mut inbound = TokioIo::new(inbound); + let mut outbound = TokioIo::new(outbound); + if let Err(error) = + tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await + { + tracing::debug!(%error, "intercept: WebSocket closed with an error"); + } + } + Err(error) => { + tracing::debug!(%error, "intercept: WebSocket upgrade did not complete") + } + } + }, + )); + return response.map(|_| empty_body()); + } + strip_hop_headers(response.headers_mut()); + response.map(BodyExt::boxed) + } +} + +async fn send( + client: &GatewayClient, + request: Request, +) -> Result, String> { + match tokio::time::timeout(RESPONSE_HEADERS_TIMEOUT, client.request(request)).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(error)) => Err(error_chain(&error)), + Err(_) => Err("the gateway did not answer in time".to_owned()), + } +} + +/// The error and its causes on one line, such as "client error (Connect): +/// Connection refused (os error 61)". The client error alone does not say why. +fn error_chain(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + message +} + +pub(super) fn empty_body() -> Body { + Empty::::new() + .map_err(|never| match never {}) + .boxed() +} + +/// Whether the request's host (the URI authority on HTTP/2, `Host` on +/// HTTP/1.1) is `host`, on the default HTTPS port. +fn request_host_matches(request: &Request, host: &str) -> bool { + let from_uri = request + .uri() + .authority() + .map(|authority| authority.as_str()); + let from_header = request + .headers() + .get(HOST) + .and_then(|value| value.to_str().ok()); + let names_host = |authority: &str| { + let (name, port) = match authority.rsplit_once(':') { + Some((name, port)) if !name.contains(':') => (name, Some(port)), + _ => (authority, None), + }; + super::normalize_host(name) == host && port.is_none_or(|port| port == "443") + }; + match (from_uri, from_header) { + (None, None) => false, + (Some(authority), None) | (None, Some(authority)) => names_host(authority), + (Some(uri), Some(header)) => names_host(uri) && names_host(header), + } +} + +fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + let lists = |name: &str, token: &str| { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|item| item.trim().eq_ignore_ascii_case(token)) + }; + lists(CONNECTION.as_str(), "upgrade") && lists("upgrade", "websocket") +} + +fn strip_hop_headers(headers: &mut HeaderMap) { + let nominated: Vec<_> = headers + .get_all(CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(|name| name.trim().to_owned()) + .collect(); + for name in nominated { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.remove(name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pool_is_dropped_after_a_wall_clock_gap_or_a_clock_jump_back() { + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let idle = Duration::from_secs(60); + assert!(!needs_fresh_pool( + base, + base + Duration::from_secs(59), + idle + )); + assert!(needs_fresh_pool(base, base + Duration::from_secs(61), idle)); + // After a night of sleep the wall clock has moved on even though the + // monotonic clock has not. + assert!(needs_fresh_pool( + base, + base + Duration::from_secs(8 * 60 * 60), + idle + )); + assert!(needs_fresh_pool(base, base - Duration::from_secs(1), idle)); + } + + #[test] + fn only_bodiless_safe_requests_are_retried() { + assert!(may_retry(&Method::GET, true)); + assert!(may_retry(&Method::HEAD, true)); + assert!(!may_retry(&Method::GET, false)); + assert!(!may_retry(&Method::POST, true)); + assert!(!may_retry(&Method::PUT, true)); + } +} diff --git a/crates/agent/src/intercept/listener.rs b/crates/agent/src/intercept/listener.rs new file mode 100644 index 0000000..30d1ac3 --- /dev/null +++ b/crates/agent/src/intercept/listener.rs @@ -0,0 +1,304 @@ +use std::{ + collections::HashSet, + convert::Infallible, + sync::{Arc, PoisonError}, + time::Duration, +}; + +use anyhow::Context; +use hyper::{Method, Request, Response, StatusCode, body::Incoming, service::service_fn}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto, +}; +use rustls::{ + ServerConfig, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; +use tokio::{net::TcpListener, task::JoinSet}; + +use super::{ + Health, InterceptCa, PendingTunnel, connect_tcp, + forward::{Body, CredentialSource, Forwarder, GatewayTarget}, + normalize_host, text_response, +}; + +/// What the listener intercepts and where it sends it. +#[derive(Debug)] +pub struct InterceptConfig { + /// Hosts that are decrypted and forwarded to the gateway, in lower case. + /// The CA must cover each of them. + pub hosts: Vec, + pub gateway: GatewayTarget, + /// Pooled gateway connections idle (on the wall clock) for longer than + /// this are dropped. Defaults to 60 s. + pub idle_reset: Option, +} + +/// The shared state of one listener. +pub struct Interceptor { + ca: Arc, + hosts: HashSet, + forwarder: Forwarder, + health: Health, +} + +impl std::fmt::Debug for Interceptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Interceptor") + .field("hosts", &self.hosts) + .finish_non_exhaustive() + } +} + +impl Interceptor { + pub fn new( + config: InterceptConfig, + ca: Arc, + credentials: Arc, + ) -> anyhow::Result> { + let hosts: HashSet = config + .hosts + .iter() + .map(|host| normalize_host(host)) + .collect(); + if hosts.is_empty() { + anyhow::bail!("the intercept listener needs at least one host"); + } + if let Some(host) = hosts.iter().find(|host| !ca.covers(host)) { + anyhow::bail!("the intercept CA does not cover {host}"); + } + Ok(Arc::new(Self { + ca, + hosts, + forwarder: Forwarder::new(config.gateway, credentials, config.idle_reset), + health: Health::default(), + })) + } + + pub fn health(&self) -> &Health { + &self.health + } +} + +/// Serve proxy connections on `listener`, which must be bound to a loopback +/// address. Runs until dropped; dropping it closes every connection and tunnel. +pub async fn serve(listener: TcpListener, interceptor: Arc) -> anyhow::Result<()> { + let local = listener.local_addr().context("read the listener address")?; + if !local.ip().is_loopback() { + anyhow::bail!("the intercept listener must be bound to a loopback address, not {local}"); + } + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + // A per-connection accept error (EMFILE, ECONNABORTED) must not + // end the listener: pause briefly so a persistent one does not + // spin, and keep accepting. + let (stream, _) = match accepted { + Ok(accepted) => accepted, + Err(error) => { + tracing::warn!(%error, "intercept: accept failed; retrying"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let interceptor = interceptor.clone(); + connections.spawn(async move { + let pending: PendingTunnel = Arc::default(); + let slot = pending.clone(); + let service = service_fn(move |request| { + let interceptor = interceptor.clone(); + let slot = slot.clone(); + async move { Ok::<_, Infallible>(proxy_request(request, &interceptor, &slot).await) } + }); + if let Err(error) = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .with_upgrades() + .await + { + tracing::debug!(%error, "intercept: proxy connection closed"); + } + let tunnel = pending.lock().unwrap_or_else(PoisonError::into_inner).take(); + if let Some(tunnel) = tunnel { + tunnel.await; + } + }); + } + Some(result) = connections.join_next(), if !connections.is_empty() => { + if let Err(error) = result { + tracing::warn!(%error, "intercept: connection task failed"); + } + } + } + } +} + +/// One request on a proxy connection. Only `CONNECT` is served: Claude sends +/// every connection that way, `https://` or not. +async fn proxy_request( + mut request: Request, + interceptor: &Arc, + slot: &PendingTunnel, +) -> Response { + if request.method() != Method::CONNECT { + return text_response( + StatusCode::METHOD_NOT_ALLOWED, + "agentdesktop intercept: only CONNECT is supported", + ); + } + let Some((host, port)) = request + .uri() + .authority() + .and_then(|authority| Some((normalize_host(authority.host()), authority.port_u16()?))) + else { + return text_response( + StatusCode::BAD_REQUEST, + "agentdesktop intercept: CONNECT needs host:port", + ); + }; + let upgrade = hyper::upgrade::on(&mut request); + + if interceptor.hosts.contains(&host) { + // An intercepted host on any other port would skip inspection. + if port != 443 { + return text_response( + StatusCode::FORBIDDEN, + "agentdesktop intercept: this host is only reachable on port 443", + ); + } + tracing::debug!(host, "intercept: decrypting"); + let interceptor = interceptor.clone(); + *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::pin(async move { + match upgrade.await { + Ok(upgraded) => { + intercepted_connection(TokioIo::new(upgraded), host, interceptor).await + } + Err(error) => { + tracing::debug!(%error, "intercept: CONNECT upgrade did not complete") + } + } + })); + return Response::new(super::forward::empty_body()); + } + + // Not intercepted: a plain tunnel. Connect before answering, so a failure + // reaches the client as a proxy error instead of a reset. + let outbound = match connect_tcp(&host, port).await { + Ok(outbound) => outbound, + Err(error) => { + tracing::debug!(host, port, %error, "intercept: tunnel destination unreachable"); + interceptor + .health + .record_tunnel_failure(format!("could not connect to {host}:{port}: {error}")); + return text_response( + StatusCode::BAD_GATEWAY, + "agentdesktop intercept: destination unreachable", + ); + } + }; + interceptor.health.record_tunnel(); + *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::pin(async move { + match upgrade.await { + Ok(upgraded) => { + let mut inbound = TokioIo::new(upgraded); + let mut outbound = outbound; + if let Err(error) = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await + { + tracing::debug!(%error, "intercept: tunnel closed with an error"); + } + } + Err(error) => tracing::debug!(%error, "intercept: CONNECT upgrade did not complete"), + } + })); + Response::new(super::forward::empty_body()) +} + +/// Terminate TLS for `host` on a `CONNECT` tunnel and serve HTTP/2 or HTTP/1.1 +/// on it, forwarding every request to the gateway. +async fn intercepted_connection( + io: TokioIo, + host: String, + interceptor: Arc, +) { + let config = match server_config(&interceptor.ca, &host) { + Ok(config) => config, + Err(error) => { + tracing::warn!(host, error = %format!("{error:#}"), "intercept: no certificate"); + return; + } + }; + let tls = match tokio_rustls::TlsAcceptor::from(config).accept(io).await { + Ok(tls) => tls, + Err(error) => { + // A client that does not trust the intercept CA, or an SNI that + // does not match the CONNECT host, ends here. + tracing::debug!(host, %error, "intercept: TLS handshake failed"); + return; + } + }; + let pending: PendingTunnel = Arc::default(); + let slot = pending.clone(); + let service = service_fn(move |request| { + let interceptor = interceptor.clone(); + let slot = slot.clone(); + let host = host.clone(); + async move { + Ok::<_, Infallible>( + interceptor + .forwarder + .forward(request, &host, &interceptor.health, &slot) + .await, + ) + } + }); + if let Err(error) = auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(tls), service) + .await + { + tracing::debug!(%error, "intercept: intercepted connection closed"); + } + let tunnel = pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + if let Some(tunnel) = tunnel { + tunnel.await; + } +} + +/// A TLS configuration that answers only for `host`, offering HTTP/2 and +/// HTTP/1.1. It does not offer HTTP/2 extended CONNECT, so WebSockets arrive +/// on HTTP/1.1 connections. +fn server_config(ca: &InterceptCa, host: &str) -> anyhow::Result> { + let resolver = Arc::new(SingleHost { + host: host.to_owned(), + key: ca.leaf(host)?, + }); + let mut config = ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_cert_resolver(resolver); + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +/// Presents the leaf only when the TLS server name is the `CONNECT` host, so a +/// tunnel opened for one host cannot be used to talk to another. +#[derive(Debug)] +struct SingleHost { + host: String, + key: Arc, +} + +impl ResolvesServerCert for SingleHost { + fn resolve(&self, hello: ClientHello<'_>) -> Option> { + hello + .server_name() + .is_some_and(|name| normalize_host(name) == self.host) + .then(|| self.key.clone()) + } +} diff --git a/crates/agent/src/intercept/mod.rs b/crates/agent/src/intercept/mod.rs new file mode 100644 index 0000000..7f407b5 --- /dev/null +++ b/crates/agent/src/intercept/mod.rs @@ -0,0 +1,174 @@ +//! Local intercepting proxy for first-party Claude Desktop. +//! +//! Claude Desktop is pinned to this listener with its `egressProxyUrl` managed +//! setting, so the app, its agent and Cowork's sandbox send every connection +//! here as an HTTP `CONNECT`. For the intercepted Anthropic hosts the listener +//! terminates TLS with a leaf from the [`InterceptCa`] and forwards each +//! request to the configured gateway with one added identity header +//! ([`IDENTITY_HEADER`]); the user's own `Cookie` and `Authorization` pass +//! through unchanged, so the claude.ai sign-in is the same as without a +//! gateway. Every other host is tunnelled without decryption. +//! +//! Nothing here connects to an intercepted host directly: when the gateway +//! cannot be reached the request fails (fail closed). +//! +//! The listener is built to keep working on a laptop that sleeps and changes +//! networks: +//! - every connection resolves its host through the system resolver at connect +//! time, so a DNS server change takes effect at once; +//! - pooled gateway connections are dropped after a wall-clock idle gap, which +//! also covers a sleep, and a request without a body that fails before any +//! response is sent once more; +//! - [`Health`] records forwarded requests and the last failure, for the +//! daemon status. + +use std::{ + io, + net::SocketAddr, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, SystemTime}, +}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::{Response, StatusCode, header::CONTENT_TYPE}; +use tokio::net::TcpStream; + +mod ca; +mod forward; +mod listener; +#[cfg(test)] +mod walkthrough; + +pub use ca::InterceptCa; +pub use forward::{CredentialSource, GatewayTarget, IDENTITY_HEADER}; +pub use listener::{InterceptConfig, Interceptor, serve}; + +/// How long one connection attempt to a resolved address may take. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// A tunnel that an upgrade (a `CONNECT` or a WebSocket) leaves behind. The +/// connection task awaits it after hyper hands the connection over, so every +/// tunnel runs under the server's task set and stops with the server. +pub(crate) type PendingTunnel = + Arc + Send>>>>>; + +/// What the listener has done, for the daemon status. Counts only: no hosts +/// beyond the failure message, no paths, no headers. +#[derive(Debug, Default)] +pub struct Health { + state: Mutex, +} + +#[derive(Clone, Debug, Default)] +pub struct HealthSnapshot { + /// Requests that the gateway answered. + pub forwarded: u64, + /// Requests that failed because the gateway or its credential was not available. + pub gateway_failures: u64, + /// Connections tunnelled without decryption. + pub tunnels: u64, + /// Tunnels that could not reach their destination. + pub tunnel_failures: u64, + /// When the gateway last answered. + pub last_forwarded: Option, + /// The last failure, with its time. + pub last_failure: Option<(SystemTime, String)>, +} + +impl Health { + pub fn snapshot(&self) -> HealthSnapshot { + self.state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn update(&self, change: impl FnOnce(&mut HealthSnapshot)) { + change(&mut self.state.lock().unwrap_or_else(PoisonError::into_inner)); + } + + fn record_forwarded(&self) { + self.update(|state| { + state.forwarded += 1; + state.last_forwarded = Some(SystemTime::now()); + }); + } + + fn record_gateway_failure(&self, message: String) { + self.update(|state| { + state.gateway_failures += 1; + state.last_failure = Some((SystemTime::now(), message)); + }); + } + + fn record_tunnel(&self) { + self.update(|state| state.tunnels += 1); + } + + fn record_tunnel_failure(&self, message: String) { + self.update(|state| { + state.tunnel_failures += 1; + state.last_failure = Some((SystemTime::now(), message)); + }); + } +} + +/// Lower case, without a trailing dot or IPv6 brackets. +fn normalize_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.') + .to_ascii_lowercase() +} + +/// Connect to `host:port`, resolving the name through the system resolver +/// (`getaddrinfo`) on every call. Nothing is cached here, so a laptop that +/// moves to another network uses the new DNS servers for the next connection. +async fn connect_tcp(host: &str, port: u16) -> io::Result { + let addresses: Vec = tokio::net::lookup_host((host, port)).await?.collect(); + let mut last_error = + io::Error::new(io::ErrorKind::NotFound, format!("{host} has no addresses")); + for address in addresses { + match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(address)).await { + Ok(Ok(stream)) => { + let _ = stream.set_nodelay(true); + return Ok(stream); + } + Ok(Err(error)) => last_error = error, + Err(_) => { + last_error = io::Error::new( + io::ErrorKind::TimedOut, + format!("connect to {address} timed out"), + ) + } + } + } + Err(last_error) +} + +/// A short plain-text response for refusals and failures. +fn text_response(status: StatusCode, message: &str) -> Response { + let mut response = Response::new( + Full::new(Bytes::from(format!("{message}\n"))) + .map_err(|never| match never {}) + .boxed(), + ); + *response.status_mut() = status; + response.headers_mut().insert( + CONTENT_TYPE, + hyper::header::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +#[cfg(test)] +mod tests { + use super::normalize_host; + + #[test] + fn hosts_are_compared_in_one_form() { + assert_eq!(normalize_host("Claude.AI."), "claude.ai"); + assert_eq!(normalize_host("[::1]"), "::1"); + } +} diff --git a/crates/agent/src/intercept/walkthrough.rs b/crates/agent/src/intercept/walkthrough.rs new file mode 100644 index 0000000..e0aae97 --- /dev/null +++ b/crates/agent/src/intercept/walkthrough.rs @@ -0,0 +1,842 @@ +//! A walkthrough of the intercept listener, end to end on loopback. It needs +//! no admin rights, no keychain and no Docker. Read it as the demo: +//! +//! ```text +//! cargo test -p agentdesktop-agent intercept::walkthrough -- --nocapture +//! ``` +//! +//! The parts: +//! - a fake gateway (plain HTTP on loopback) that records what it receives and +//! serves a stream, an upload and a WebSocket echo, standing in for +//! agentgateway and Anthropic; +//! - a separate TLS origin with its own CA, standing in for a host that is not +//! intercepted; +//! - the intercept listener, with a freshly generated intercept CA; +//! - clients that trust only the CA they are given, the way Claude Desktop +//! trusts the intercept CA once it is in the System keychain. +//! +//! What it does not show: Claude Desktop itself, the macOS trust store, the +//! `egressProxyUrl` pin, or a real agentgateway. Those were covered by the +//! manual acceptance run. + +use std::{ + convert::Infallible, + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody}; +use hyper::{ + Request, Response, StatusCode, + body::{Frame, Incoming}, + service::service_fn, +}; +use hyper_util::rt::TokioIo; +use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, KeyUsagePurpose}; +use rustls::{ + ClientConfig, RootCertStore, ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +use super::{ + CredentialSource, GatewayTarget, IDENTITY_HEADER, InterceptCa, InterceptConfig, Interceptor, + serve, +}; + +const IDENTITY: &str = "walkthrough-gateway-jwt"; +const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +fn step(text: &str) { + println!("\n== {text}"); +} + +fn note(text: impl std::fmt::Display) { + println!(" {text}"); +} + +fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) +} + +struct StaticCredential; + +#[async_trait::async_trait] +impl CredentialSource for StaticCredential { + async fn credential(&self) -> Result, String> { + Ok(Some(IDENTITY.to_owned())) + } +} + +/// What the fake gateway saw on one request. +#[derive(Clone, Debug)] +struct Seen { + version: String, + host: String, + path: String, + identity: Vec, + cookie: Option, + authorization: Option, +} + +/// A gateway on loopback that records requests and counts the connections it +/// accepted. Plain HTTP/1.1, or TLS with HTTP/2 and HTTP/1.1 by ALPN. +struct FakeGateway { + address: SocketAddr, + seen: Arc>>, + connections: Arc, +} + +type FixtureBody = BoxBody; + +fn full(text: impl Into) -> FixtureBody { + Full::new(text.into()).boxed() +} + +impl FakeGateway { + async fn start(tls: Option>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let seen: Arc>> = Arc::default(); + let connections: Arc = Arc::default(); + let (record, count) = (seen.clone(), connections.clone()); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + count.fetch_add(1, Ordering::SeqCst); + let record = record.clone(); + let tls = tls.clone(); + tokio::spawn(async move { + let service = service_fn(move |request| { + let record = record.clone(); + async move { Ok::<_, Infallible>(gateway_route(request, &record).await) } + }); + match tls { + None => { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .with_upgrades() + .await; + } + Some(config) => { + let Ok(tls) = + tokio_rustls::TlsAcceptor::from(config).accept(stream).await + else { + return; + }; + let _ = hyper_util::server::conn::auto::Builder::new( + hyper_util::rt::TokioExecutor::new(), + ) + .serve_connection_with_upgrades(TokioIo::new(tls), service) + .await; + } + } + }); + } + }); + Self { + address, + seen, + connections, + } + } + + fn last(&self) -> Seen { + self.seen + .lock() + .unwrap() + .last() + .cloned() + .expect("the gateway saw a request") + } + + fn requests(&self) -> usize { + self.seen.lock().unwrap().len() + } + + fn connections(&self) -> usize { + self.connections.load(Ordering::SeqCst) + } +} + +async fn gateway_route( + mut request: Request, + record: &Mutex>, +) -> Response { + let header = |name: &str| { + request + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + let seen = Seen { + version: format!("{:?}", request.version()), + // HTTP/2 carries the host as :authority, which hyper puts in the URI. + host: header("host") + .or_else(|| { + request + .uri() + .authority() + .map(|authority| authority.to_string()) + }) + .unwrap_or_default(), + path: request.uri().path().to_owned(), + identity: request + .headers() + .get_all(IDENTITY_HEADER) + .iter() + .filter_map(|value| value.to_str().ok().map(str::to_owned)) + .collect(), + cookie: header("cookie"), + authorization: header("authorization"), + }; + let websocket_key = header("sec-websocket-key").unwrap_or_default(); + let path = seen.path.clone(); + record.lock().unwrap().push(seen); + match path.as_str() { + "/sse" => { + // Three events, 250 ms apart: a client that gets them all at once + // is reading a buffered body. + let (sender, receiver) = + tokio::sync::mpsc::channel::, Infallible>>(4); + tokio::spawn(async move { + for index in 0..3 { + let event = Frame::data(Bytes::from(format!("data: event {index}\n\n"))); + if sender.send(Ok(event)).await.is_err() { + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }); + let mut response = Response::new( + StreamBody::new(tokio_stream::wrappers::ReceiverStream::new(receiver)).boxed(), + ); + response + .headers_mut() + .insert("content-type", "text/event-stream".parse().unwrap()); + response + } + "/upload" => { + let mut body = request.into_body(); + let mut total = 0usize; + while let Some(frame) = body.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + total += data.len(); + } + } + Response::new(full(total.to_string())) + } + "/ws" => { + let key = websocket_key; + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + let mut stream = TokioIo::new(upgrade.await.unwrap()); + // Echo text frames until the client goes away. + while let Some((_, payload)) = read_frame(&mut stream).await { + if stream.write_all(&frame(&payload, false)).await.is_err() { + return; + } + } + }); + Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header("connection", "upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-accept", ws_accept(&key)) + .body(full("")) + .unwrap() + } + _ => Response::new(full("hello from the gateway")), + } +} + +/// A TLS server configuration for "localhost", signed by a CA of its own, and +/// that CA. `alpn` lists the protocols it offers. +fn localhost_tls(alpn: &[&[u8]]) -> (Arc, CertificateDer<'static>) { + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign]; + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + let ca_der = ca_cert.der().clone(); + let issuer = Issuer::new(ca_params, ca_key); + let leaf_key = KeyPair::generate().unwrap(); + let leaf = CertificateParams::new(vec!["localhost".to_owned()]) + .unwrap() + .signed_by(&leaf_key, &issuer) + .unwrap(); + let mut config = ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![leaf.der().clone(), ca_der.clone()], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf_key.serialize_der())), + ) + .unwrap(); + config.alpn_protocols = alpn.iter().map(|proto| proto.to_vec()).collect(); + (Arc::new(config), ca_der) +} + +/// A TLS origin for "localhost" with its own CA: a host that is not intercepted. +async fn start_other_origin() -> (SocketAddr, CertificateDer<'static>) { + let (config, ca_der) = localhost_tls(&[b"http/1.1"]); + let acceptor = tokio_rustls::TlsAcceptor::from(config); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(tls) = acceptor.accept(stream).await else { + return; + }; + let service = service_fn(|_request| async { + Ok::<_, Infallible>(Response::new(full("hello from the other origin"))) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await; + }); + } + }); + (address, ca_der) +} + +/// A TLS server that presents one fixed certificate: used to show what a +/// verifier does with leaves signed by the intercept CA. +async fn serve_fixed_certificate(key: Arc) -> SocketAddr { + #[derive(Debug)] + struct Fixed(Arc); + impl ResolvesServerCert for Fixed { + fn resolve(&self, _hello: ClientHello<'_>) -> Option> { + Some(self.0.clone()) + } + } + let config = ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_cert_resolver(Arc::new(Fixed(key))); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + tokio::spawn(async move { + if let Ok(mut tls) = acceptor.accept(stream).await { + let _ = tls.shutdown().await; + } + }); + } + }); + address +} + +fn plain_gateway(address: SocketAddr) -> GatewayTarget { + GatewayTarget::http(address.ip().to_string(), address.port()) +} + +async fn start_listener( + ca: &Arc, + gateway: GatewayTarget, + idle_reset: Option, +) -> (SocketAddr, Arc) { + let interceptor = Interceptor::new( + InterceptConfig { + hosts: vec!["claude.ai".to_owned(), "api.anthropic.com".to_owned()], + gateway, + idle_reset, + }, + ca.clone(), + Arc::new(StaticCredential), + ) + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(serve(listener, interceptor.clone())); + (address, interceptor) +} + +/// An HTTPS client that sends everything through `proxy` and trusts only `root`. +fn client(proxy: SocketAddr, root: &CertificateDer<'static>) -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .proxy(reqwest::Proxy::all(format!("http://{proxy}")).unwrap()) + .tls_certs_only([reqwest::Certificate::from_der(root).unwrap()]) + .build() + .unwrap() +} + +fn tls_connector(root: &CertificateDer<'static>, alpn: &[&[u8]]) -> tokio_rustls::TlsConnector { + let mut roots = RootCertStore::empty(); + roots.add(root.clone()).unwrap(); + let mut config = ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = alpn.iter().map(|proto| proto.to_vec()).collect(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +/// Send `CONNECT target` and return the status code and, on 200, the stream. +async fn connect_through(proxy: SocketAddr, target: &str) -> (u16, TcpStream) { + let mut stream = TcpStream::connect(proxy).await.unwrap(); + stream + .write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes()) + .await + .unwrap(); + let status = read_status(&mut stream).await; + (status, stream) +} + +/// Read an HTTP/1.1 response head and return its status code. +async fn read_status(stream: &mut S) -> u16 { + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).await.unwrap() == 0 { + break; + } + head.push(byte[0]); + } + let head = String::from_utf8_lossy(&head); + head.split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .unwrap_or(0) +} + +fn ws_accept(key: &str) -> String { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use sha1::{Digest, Sha1}; + STANDARD.encode(Sha1::digest(format!("{key}{WS_GUID}").as_bytes())) +} + +fn frame(payload: &[u8], mask: bool) -> Vec { + let mut frame = vec![0x81u8]; + let mask_bit = if mask { 0x80 } else { 0x00 }; + assert!(payload.len() < 126, "short frames only"); + frame.push(mask_bit | payload.len() as u8); + if mask { + let key = [0x11u8, 0x22, 0x33, 0x44]; + frame.extend_from_slice(&key); + frame.extend( + payload + .iter() + .enumerate() + .map(|(index, byte)| byte ^ key[index % 4]), + ); + } else { + frame.extend_from_slice(payload); + } + frame +} + +async fn read_frame(stream: &mut S) -> Option<(u8, Vec)> { + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.ok()?; + let masked = header[1] & 0x80 != 0; + let len = usize::from(header[1] & 0x7f); + let mut key = [0u8; 4]; + if masked { + stream.read_exact(&mut key).await.ok()?; + } + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload).await.ok()?; + if masked { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= key[index % 4]; + } + } + Some((header[0] & 0x0f, payload)) +} + +async fn tls_over( + connector: &tokio_rustls::TlsConnector, + name: &str, + stream: S, +) -> std::io::Result> { + connector + .connect(ServerName::try_from(name.to_owned()).unwrap(), stream) + .await +} + +#[tokio::test] +async fn walkthrough() { + step("1. An intercept CA is generated for claude.ai and api.anthropic.com"); + let ca = Arc::new( + InterceptCa::generate( + "agentdesktop intercept CA (walkthrough)", + &["claude.ai".to_owned(), "api.anthropic.com".to_owned()], + ) + .unwrap(), + ); + note("Name Constraints: only those names and their subdomains; no IP address"); + let intercept_root = ca.certificate_der().clone(); + + step("2. A fake gateway and the intercept listener start on loopback"); + let gateway = FakeGateway::start(None).await; + let (proxy, interceptor) = start_listener(&ca, plain_gateway(gateway.address), None).await; + note(format!( + "gateway http://{} listener http://{proxy}", + gateway.address + )); + let claude = client(proxy, &intercept_root); + + step("3. A request to claude.ai is decrypted and sent to the gateway with the identity header"); + let response = claude + .get("https://claude.ai/api/bootstrap") + .header("cookie", "sessionKey=user-session") + .header("authorization", "Bearer user-token") + .header(IDENTITY_HEADER, "Bearer spoofed-by-a-local-process") + .send() + .await + .unwrap(); + let version = response.version(); + assert_eq!(response.status(), 200); + assert_eq!(response.text().await.unwrap(), "hello from the gateway"); + assert_eq!( + version, + reqwest::Version::HTTP_2, + "the listener serves HTTP/2 by ALPN" + ); + let seen = gateway.last(); + note(format!( + "client spoke {version:?}; the gateway saw Host {}, {} identity header(s), cookie and authorization unchanged", + seen.host, + seen.identity.len() + )); + assert_eq!(seen.host, "claude.ai", "the original host is kept"); + assert_eq!(seen.version, "HTTP/1.1", "a plain gateway gets HTTP/1.1"); + assert_eq!(seen.path, "/api/bootstrap"); + assert_eq!( + seen.identity, + vec![format!("Bearer {IDENTITY}")], + "the spoofed header is replaced" + ); + assert_eq!(seen.cookie.as_deref(), Some("sessionKey=user-session")); + assert_eq!(seen.authorization.as_deref(), Some("Bearer user-token")); + + step("4. A server-sent event stream arrives event by event, not buffered"); + let started = Instant::now(); + let mut stream = claude.get("https://claude.ai/sse").send().await.unwrap(); + let mut arrivals = Vec::new(); + while let Some(chunk) = stream.chunk().await.unwrap() { + if !chunk.is_empty() { + arrivals.push(( + started.elapsed(), + String::from_utf8_lossy(&chunk).trim().to_owned(), + )); + } + } + for (at, event) in &arrivals { + note(format!("{:>5} ms {event}", at.as_millis())); + } + assert!(arrivals.len() >= 3, "three separate events"); + let spread = arrivals.last().unwrap().0 - arrivals.first().unwrap().0; + assert!( + spread >= Duration::from_millis(400), + "events 250 ms apart, got {spread:?}" + ); + + step("5. A 4 MiB upload to api.anthropic.com streams through to the gateway"); + let body = vec![b'x'; 4 * 1024 * 1024]; + let received = claude + .post("https://api.anthropic.com/upload") + .body(body) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + note(format!( + "the gateway counted {received} bytes for Host {}", + gateway.last().host + )); + assert_eq!(received, (4 * 1024 * 1024).to_string()); + assert_eq!(gateway.last().host, "api.anthropic.com"); + + step("6. A WebSocket on claude.ai is upgraded and relays both ways, and stays open while idle"); + let (status, tunnel) = connect_through(proxy, "claude.ai:443").await; + assert_eq!(status, 200); + let mut socket = tls_over( + &tls_connector(&intercept_root, &[b"http/1.1"]), + "claude.ai", + tunnel, + ) + .await + .unwrap(); + socket + .write_all( + b"GET /ws HTTP/1.1\r\nHost: claude.ai\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\ + Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await + .unwrap(); + assert_eq!(read_status(&mut socket).await, 101); + for (message, idle) in [ + ("hello", Duration::ZERO), + ("still here", Duration::from_millis(300)), + ] { + tokio::time::sleep(idle).await; + socket + .write_all(&frame(message.as_bytes(), true)) + .await + .unwrap(); + let (_, echoed) = read_frame(&mut socket).await.expect("an echo"); + note(format!( + "sent {message:?} after {} ms idle, got {:?} back", + idle.as_millis(), + String::from_utf8_lossy(&echoed) + )); + assert_eq!(echoed, message.as_bytes()); + } + + step("7. A host that is not intercepted is tunnelled, not decrypted"); + let (origin, origin_root) = start_other_origin().await; + let text = client(proxy, &origin_root) + .get(format!("https://localhost:{}/", origin.port())) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + note(format!( + "a client that trusts only the origin's own CA got: {text:?}" + )); + assert_eq!(text, "hello from the other origin"); + let refused = client(proxy, &intercept_root) + .get(format!("https://localhost:{}/", origin.port())) + .send() + .await; + note( + "a client that trusts only the intercept CA is refused: the listener never minted a certificate", + ); + assert!(refused.is_err()); + + step("8. Requests that try to reach another host through an intercepted tunnel are refused"); + let (_, tunnel) = connect_through(proxy, "claude.ai:443").await; + let mut tls = tls_over( + &tls_connector(&intercept_root, &[b"http/1.1"]), + "claude.ai", + tunnel, + ) + .await + .unwrap(); + tls.write_all(b"GET / HTTP/1.1\r\nHost: api.anthropic.com\r\n\r\n") + .await + .unwrap(); + let mismatch = read_status(&mut tls).await; + note(format!( + "CONNECT claude.ai, then Host: api.anthropic.com -> {mismatch}" + )); + assert_eq!(mismatch, 421); + let (_, tunnel) = connect_through(proxy, "claude.ai:443").await; + let sni = tls_over( + &tls_connector(&intercept_root, &[b"http/1.1"]), + "api.anthropic.com", + tunnel, + ) + .await; + note(format!( + "CONNECT claude.ai, then TLS for api.anthropic.com -> {}", + if sni.is_err() { + "handshake refused" + } else { + "accepted" + } + )); + assert!(sni.is_err()); + let (other_port, _) = connect_through(proxy, "claude.ai:8443").await; + note(format!("CONNECT claude.ai:8443 -> {other_port}")); + assert_eq!(other_port, 403); + let mut plain = TcpStream::connect(proxy).await.unwrap(); + plain + .write_all(b"GET http://claude.ai/ HTTP/1.1\r\nHost: claude.ai\r\n\r\n") + .await + .unwrap(); + let not_connect = read_status(&mut plain).await; + note(format!("plain-HTTP proxy request -> {not_connect}")); + assert_eq!(not_connect, 405); + + step("9. A verifier rejects names outside the intercept CA's Name Constraints"); + let verifier = tls_connector(&intercept_root, &[]); + for (name, key) in [ + ("claude.ai", ca.leaf("claude.ai").unwrap()), + ("example.com", ca.leaf_unchecked("example.com").unwrap()), + ("127.0.0.1", ca.leaf_unchecked("127.0.0.1").unwrap()), + ] { + let server = serve_fixed_certificate(key).await; + let outcome = tls_over(&verifier, name, TcpStream::connect(server).await.unwrap()).await; + note(format!( + "leaf for {name:<12} -> {}", + match &outcome { + Ok(_) => "accepted".to_owned(), + Err(error) => format!("rejected ({error})"), + } + )); + assert_eq!(outcome.is_ok(), name == "claude.ai"); + } + note("this is rustls-webpki; Electron's verifier and Apple's SecTrust are not tested here"); + + step("10. With the gateway down, requests fail (fail closed) and the health shows why"); + let closed = TcpListener::bind("127.0.0.1:0") + .await + .unwrap() + .local_addr() + .unwrap(); + let (down_proxy, down) = start_listener(&ca, plain_gateway(closed), None).await; + let before = gateway.requests(); + let response = client(down_proxy, &intercept_root) + .get("https://claude.ai/api/bootstrap") + .send() + .await + .unwrap(); + let health = down.health().snapshot(); + note(format!( + "status {}, gateway failures {}, last failure: {}", + response.status(), + health.gateway_failures, + health + .last_failure + .as_ref() + .map_or("none", |(_, message)| message.as_str()) + )); + assert_eq!(response.status(), 502); + assert!(health.gateway_failures >= 1 && health.last_failure.is_some()); + assert_eq!( + gateway.requests(), + before, + "nothing else answered in the gateway's place" + ); + + step("11. Pooled gateway connections are dropped after an idle gap (or a sleep)"); + let (idle_proxy, _) = start_listener( + &ca, + plain_gateway(gateway.address), + Some(Duration::from_millis(200)), + ) + .await; + let idle_client = client(idle_proxy, &intercept_root); + let quick = gateway.connections(); + for _ in 0..2 { + idle_client + .get("https://claude.ai/") + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + } + let quick = gateway.connections() - quick; + let gapped = gateway.connections(); + for _ in 0..2 { + tokio::time::sleep(Duration::from_millis(400)).await; + idle_client + .get("https://claude.ai/") + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + } + let gapped = gateway.connections() - gapped; + note(format!( + "two requests back to back: {quick} new gateway connection(s); two after a gap: {gapped}" + )); + assert_eq!( + quick, 1, + "back-to-back requests reuse the pooled connection" + ); + assert_eq!( + gapped, 2, + "a gap longer than the idle reset starts a new connection" + ); + + step("12. Over TLS, the gateway connection uses HTTP/2 and keeps the original host"); + let (gateway_tls, gateway_root) = localhost_tls(&[b"h2", b"http/1.1"]); + let tls_gateway = FakeGateway::start(Some(gateway_tls)).await; + let mut roots = RootCertStore::empty(); + roots.add(gateway_root).unwrap(); + let target = GatewayTarget::https("localhost", tls_gateway.address.port(), roots).unwrap(); + let (tls_proxy, _) = start_listener(&ca, target, None).await; + let text = client(tls_proxy, &intercept_root) + .get("https://claude.ai/over-tls") + .header("cookie", "sessionKey=user-session") + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let seen = tls_gateway.last(); + note(format!( + "the TLS gateway saw {} with host {} and {} identity header(s)", + seen.version, + seen.host, + seen.identity.len() + )); + assert_eq!(text, "hello from the gateway"); + assert_eq!(seen.version, "HTTP/2.0"); + assert_eq!(seen.host, "claude.ai"); + assert_eq!(seen.identity, vec![format!("Bearer {IDENTITY}")]); + let (_, tunnel) = connect_through(tls_proxy, "claude.ai:443").await; + let mut socket = tls_over( + &tls_connector(&intercept_root, &[b"http/1.1"]), + "claude.ai", + tunnel, + ) + .await + .unwrap(); + socket + .write_all( + b"GET /ws HTTP/1.1\r\nHost: claude.ai\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\ + Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await + .unwrap(); + assert_eq!(read_status(&mut socket).await, 101); + socket.write_all(&frame(b"over tls", true)).await.unwrap(); + let (_, echoed) = read_frame(&mut socket).await.expect("an echo"); + note(format!( + "a WebSocket through the TLS gateway went over {} and echoed {:?}", + tls_gateway.last().version, + String::from_utf8_lossy(&echoed) + )); + assert_eq!(echoed, b"over tls"); + assert_eq!( + tls_gateway.last().version, + "HTTP/1.1", + "upgrades use HTTP/1.1" + ); + + step("13. The main listener's health"); + let health = interceptor.health().snapshot(); + note(format!( + "forwarded {}, gateway failures {}, tunnels {}, tunnel failures {}", + health.forwarded, health.gateway_failures, health.tunnels, health.tunnel_failures + )); + assert!(health.forwarded >= 4); + assert_eq!(health.gateway_failures, 0); + assert!(health.tunnels >= 2); +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 1bf0f6d..eeb961e 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -6,6 +6,7 @@ pub mod enrollment; pub mod gateway_oidc; mod github_oauth; pub mod identity; +pub mod intercept; mod inventory_watch; mod llm_proxy; pub mod oidc; From 566b5f12403f90a023894a6cafc98ded1bc0cafc Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 12:17:05 -0400 Subject: [PATCH 2/9] feat(agent): run the intercept listener for Claude Desktop in first-party mode Wire the intercept listener into the system daemon and pin Claude Desktop to it. Configuration: - programs.claudeDesktop.gatewayMode: inference (the default, today's third-party mode) or intercept. - llmGateway.interceptUrl: where the listener forwards. Only the scheme, host and port are used. Without it, the listener uses those parts of llmGateway.url. - daemon.intercept.listen: the loopback listener address, which defaults to 127.0.0.1:18471. It is rejected in user mode. - Validation refuses intercept mode without a gateway, together with auth, or with pass-through keys that start with "inference" or "egressProxy", which would undo it. It also refuses an interceptUrl with a path. Daemon: - A supervisor in the system daemon follows the applied configuration. It starts the listener while Claude Desktop is in intercept mode, restarts it when the gateway changes, and stops it otherwise. When the listener cannot start, the supervisor tries again every 10 s. - The intercept CA key is kept in the daemon's secret store and the certificate in /intercept-ca.pem. A new CA is made when the intercepted hosts change, and the daemon logs its path and SHA-256. - The identity header carries the same gateway credential that the Claude Desktop credential helper gets today (client id claude-desktop). It is reused for at most 60 s and never within 30 s of its expiry. - daemon-info has an intercept section: enabled, bound, the last error, the CA path and fingerprint, counts, and the last failure with its cause. Provider: - In intercept mode the Claude Desktop managed settings get egressProxyUrl pointing at the listener and no inference keys, and the credential helper is removed. The pin is written even when the listener is down, so Claude Desktop fails instead of connecting directly. The schema is regenerated. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/agent/Cargo.toml | 1 + crates/agent/src/api.rs | 6 +- crates/agent/src/daemon.rs | 74 +++- crates/agent/src/intercept/ca.rs | 48 ++- crates/agent/src/intercept/mod.rs | 9 + crates/agent/src/intercept/run.rs | 387 ++++++++++++++++++ crates/agent/src/intercept/store.rs | 153 +++++++ crates/agent/src/llm_proxy.rs | 11 +- crates/agent/src/provider/claude_code/mod.rs | 1 + .../agent/src/provider/claude_desktop/mod.rs | 1 + .../src/provider/claude_desktop/reconcile.rs | 143 ++++++- crates/agent/src/provider/copilot/tests.rs | 2 + crates/agent/src/provider/mod.rs | 5 + .../src/provider/vscode/settings_tests.rs | 2 + crates/agent/src/provider/vscode/tests.rs | 3 + crates/agent/src/reconcile/mod.rs | 8 + crates/core/src/config.rs | 190 ++++++++- crates/core/src/model.rs | 41 ++ schema/daemon-config.json | 45 ++ schema/daemon-config.md | 4 + 22 files changed, 1117 insertions(+), 19 deletions(-) create mode 100644 crates/agent/src/intercept/run.rs create mode 100644 crates/agent/src/intercept/store.rs diff --git a/Cargo.lock b/Cargo.lock index 2c9c65c..9281586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,7 @@ dependencies = [ "rcgen", "reqwest", "rustls", + "rustls-native-certs", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 9f134b2..7db3caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ hostname = "0.4" hyper = { version = "1", features = ["client", "http1", "server"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "native-tokio", "aws-lc-rs"] } rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +rustls-native-certs = "0.8" hyper-util = { version = "0.1", features = ["service", "tokio"] } memchr = "2.8" notify = "8.2" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 6c1f3b7..06648ce 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -33,6 +33,7 @@ clap.workspace = true hostname.workspace = true hyper = { workspace = true, features = ["http2"] } hyper-rustls.workspace = true +rustls-native-certs.workspace = true hyper-util = { workspace = true, features = ["client-legacy", "http1", "http2", "server-auto"] } http-body-util.workspace = true json5.workspace = true diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index e2f4fd0..7529dd5 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -46,6 +46,8 @@ pub struct AppState { pub usage: Option, /// The device user's program choice; `None` serves no program routes. pub programs: Option, + /// The intercept listener's state, for daemon-info; `None` in user mode. + pub intercept: Option>, } /// Reads and changes the device user's program choice, and re-applies the @@ -333,7 +335,9 @@ async fn health(State(state): State) -> Json { } async fn daemon_info(State(state): State) -> Json { - Json(state.daemon_info) + let mut info = state.daemon_info; + info.intercept = state.intercept.as_ref().map(|status| status.info()); + Json(info) } async fn config(State(state): State) -> Json { diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index c60a946..f06d4cf 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -37,8 +37,8 @@ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; #[cfg(windows)] use crate::windows_security::SecurityDescriptor; use crate::{ - api, enrollment::EnrollmentState, gateway_oidc, inventory_watch::InventoryWatch, llm_proxy, - reconcile, remote, secure_fs, + api, enrollment::EnrollmentState, gateway_oidc, intercept, inventory_watch::InventoryWatch, + llm_proxy, reconcile, remote, secure_fs, }; #[cfg(unix)] @@ -79,6 +79,9 @@ struct ResolvedDaemonArgs { oidc_callback_listen: Option, llm_proxy_listen: Option, llm_proxy_client_id: String, + /// The intercept listener address (`daemon.intercept.listen` or its + /// default); `None` in user mode, where Claude Desktop is not managed. + intercept_listen: Option, /// `daemon.usage`; `None` for `--once` and `--dry-run`, which serve nothing. usage: Option, claude_code: ResolvedToolConfigPath, @@ -148,6 +151,13 @@ impl DaemonArgs { .usage .clone() .filter(|_| !(self.once || self.dry_run)); + // Claude Desktop's managed settings are system-wide, so only the + // system daemon pins it to the intercept listener. + if user && startup.intercept.listen.is_some() { + bail!( + "daemon.intercept.listen is for the system daemon; it cannot be used with --user" + ); + } // VS Code's chatLanguageModels.json and settings.json live in the // user's own profile, like the Copilot CLI providers file: a system // daemon has no user file to manage, so an explicit override is @@ -173,6 +183,12 @@ impl DaemonArgs { oidc_callback_listen: startup.oidc_callback_listen, llm_proxy_listen: None, llm_proxy_client_id: llm_proxy_client_id.clone(), + intercept_listen: Some( + startup + .intercept + .listen + .unwrap_or_else(config::default_intercept_listen), + ), usage, claude_code: ResolvedToolConfigPath { config: startup.claude_code.config.unwrap_or_else(|| { @@ -250,6 +266,7 @@ impl DaemonArgs { oidc_callback_listen: startup.oidc_callback_listen, llm_proxy_listen, llm_proxy_client_id, + intercept_listen: None, usage, claude_code: ResolvedToolConfigPath { config: startup @@ -436,7 +453,8 @@ where agentdesktop_client_executable()?, socket.clone(), ) - .with_llm_proxy(proxy_context); + .with_llm_proxy(proxy_context) + .with_intercept(args.intercept_listen); let choice = crate::program_choice::load(&args.state_dir); if choice != agentdesktop_core::model::ProgramChoice::All { tracing::info!( @@ -600,6 +618,9 @@ where )); } let has_controller = config.controller.is_some(); + let intercept_status = args + .intercept_listen + .map(|listen| intercept::Status::new(listen, args.state_dir.clone())); let state = api::AppState { config, daemon_info, @@ -612,7 +633,18 @@ where logout, usage: usage_store, programs: Some(program_control), + intercept: intercept_status.clone(), }; + // The intercept listener follows the applied configuration: it runs while + // programs.claudeDesktop.gatewayMode is intercept. + if let (Some(listen), Some(status)) = (args.intercept_listen, intercept_status) { + tokio::spawn(intercept::supervise( + listen, + state.clone(), + current_config.subscribe(), + status, + )); + } let app = api::router(state.clone()); // The proxy is optional at runtime too: if serving fails (for example the // TLS root store cannot be built), log it and keep the daemon running @@ -820,6 +852,7 @@ fn describe_daemon( controller, llm_proxy, usage_receiver: None, + intercept: None, } } @@ -1592,6 +1625,7 @@ programs: logout: None, usage: None, programs: None, + intercept: None, }); for (method, expected_status) in [("GET", "200 OK"), ("POST", "405 Method Not Allowed")] { @@ -1731,6 +1765,7 @@ programs: logout: None, usage: store, programs: None, + intercept: None, }); let (mut client, server) = tokio::io::duplex(64 * 1024); let connection = tokio::spawn(serve_local_connection(server, app)); @@ -1817,6 +1852,7 @@ programs: logout: None, usage: None, programs: None, + intercept: None, }); for endpoint in ["/v1/health", "/v1/config", "/v1/daemon-info"] { @@ -2105,6 +2141,38 @@ programs: startup } + #[test] + fn the_intercept_listener_address_is_for_the_system_daemon() { + let resolved = daemon_args(false) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!( + resolved.intercept_listen, + Some(config::default_intercept_listen()) + ); + let user = daemon_args(true) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!(user.intercept_listen, None); + let mut startup = config::DaemonStartupConfig::default(); + startup.intercept.listen = Some("127.0.0.1:18500".parse().unwrap()); + let error = resolve_error(daemon_args(true), startup); + assert!( + error + .to_string() + .contains("daemon.intercept.listen is for the system daemon"), + "{error:#}" + ); + } + #[test] fn system_mode_rejects_a_copilot_providers_path() { let mut startup = config::DaemonStartupConfig::default(); diff --git a/crates/agent/src/intercept/ca.rs b/crates/agent/src/intercept/ca.rs index 732b63d..679ed41 100644 --- a/crates/agent/src/intercept/ca.rs +++ b/crates/agent/src/intercept/ca.rs @@ -11,7 +11,7 @@ use rcgen::{ NameConstraints, }; use rustls::{ - pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, pem::PemObject}, sign::CertifiedKey, }; use time::OffsetDateTime; @@ -40,6 +40,8 @@ pub struct InterceptCa { issuer: Issuer<'static, KeyPair>, certificate_der: CertificateDer<'static>, certificate_pem: String, + /// The CA private key, for the daemon's secret store. Never logged. + key_pem: String, hosts: Vec, leaves: Mutex>, } @@ -92,12 +94,45 @@ impl InterceptCa { Ok(Self { certificate_der: certificate.der().clone(), certificate_pem: certificate.pem(), + key_pem: key.serialize_pem(), issuer: Issuer::new(params, key), hosts: hosts.to_vec(), leaves: Mutex::default(), }) } + /// Load a CA that [`InterceptCa::generate`] made for `hosts` earlier. The + /// caller keeps the host list with the certificate: a CA is only reused + /// for the hosts its constraints were written for. + pub fn from_pem( + certificate_pem: &str, + key_pem: &str, + hosts: &[String], + ) -> anyhow::Result { + let key = KeyPair::from_pem(key_pem).context("parse the intercept CA key")?; + let certificate_der = CertificateDer::from_pem_slice(certificate_pem.as_bytes()) + .context("parse the intercept CA certificate")?; + let issuer = Issuer::from_ca_cert_der(&certificate_der, key) + .context("load the intercept CA certificate")?; + Ok(Self { + issuer, + certificate_der, + certificate_pem: certificate_pem.to_owned(), + key_pem: key_pem.to_owned(), + hosts: hosts.to_vec(), + leaves: Mutex::default(), + }) + } + + pub(super) fn key_pem(&self) -> &str { + &self.key_pem + } + + /// The hosts this CA may issue for. + pub fn hosts(&self) -> &[String] { + &self.hosts + } + /// The CA certificate, for a trust store or an MDM profile. pub fn certificate_pem(&self) -> &str { &self.certificate_pem @@ -187,6 +222,17 @@ mod tests { assert!(ca.leaf("claude.ai.example.com").is_err()); } + #[test] + fn a_stored_ca_loads_and_still_mints() { + let generated = InterceptCa::generate("test intercept CA", &hosts()).unwrap(); + let loaded = + InterceptCa::from_pem(generated.certificate_pem(), generated.key_pem(), &hosts()) + .unwrap(); + assert_eq!(loaded.certificate_der(), generated.certificate_der()); + let leaf = loaded.leaf("api.anthropic.com").unwrap(); + assert_eq!(&leaf.cert[1], generated.certificate_der()); + } + #[test] fn an_empty_host_list_is_refused() { assert!(InterceptCa::generate("test intercept CA", &[]).is_err()); diff --git a/crates/agent/src/intercept/mod.rs b/crates/agent/src/intercept/mod.rs index 7f407b5..31794e4 100644 --- a/crates/agent/src/intercept/mod.rs +++ b/crates/agent/src/intercept/mod.rs @@ -37,12 +37,21 @@ use tokio::net::TcpStream; mod ca; mod forward; mod listener; +mod run; +mod store; #[cfg(test)] mod walkthrough; pub use ca::InterceptCa; pub use forward::{CredentialSource, GatewayTarget, IDENTITY_HEADER}; pub use listener::{InterceptConfig, Interceptor, serve}; +pub use run::Status; +pub(crate) use run::supervise; +pub use store::{certificate_path, fingerprint}; + +/// The hosts the daemon decrypts and forwards to the gateway. Every other host +/// is tunnelled. +pub const INTERCEPTED_HOSTS: [&str; 2] = ["claude.ai", "api.anthropic.com"]; /// How long one connection attempt to a resolved address may take. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); diff --git a/crates/agent/src/intercept/run.rs b/crates/agent/src/intercept/run.rs new file mode 100644 index 0000000..95fe23c --- /dev/null +++ b/crates/agent/src/intercept/run.rs @@ -0,0 +1,387 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use agentdesktop_core::{ + config::{ClaudeDesktopGatewayMode, DaemonConfig}, + model::InterceptInfo, +}; +use anyhow::Context; +use rustls::RootCertStore; +use tokio::{net::TcpListener, sync::watch, task::JoinHandle}; +use url::Url; + +use super::{ + CredentialSource, GatewayTarget, INTERCEPTED_HOSTS, InterceptConfig, Interceptor, serve, store, +}; +use crate::{api, tick::CurrentConfig}; + +/// The credential policy client id the identity header is issued for: the same +/// one Claude Desktop's credential helper uses in inference mode. +const CLIENT_ID: &str = "claude-desktop"; +/// How long a request waits for a credential fetch before it fails. +const CREDENTIAL_FETCH_TIMEOUT: Duration = Duration::from_secs(20); +/// A cached credential is not used within this margin of its expiry. +const CREDENTIAL_EXPIRY_MARGIN: Duration = Duration::from_secs(30); +/// Longest a credential is reused after it was fetched, so a revoked device +/// stops being served soon (the gateway validates tokens statelessly). +const CREDENTIAL_REUSE: Duration = Duration::from_secs(60); +/// Wait before trying again when the listener could not start. +const RETRY_AFTER: Duration = Duration::from_secs(10); + +/// What the listener needs from the applied configuration. A change restarts it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Wanted { + /// The gateway's scheme, host and port. + gateway: Url, +} + +/// The listener settings `config` asks for, or `None` when Claude Desktop is not +/// in intercept mode. +pub(crate) fn wanted(config: &DaemonConfig) -> Option { + let desktop = config.programs.claude_desktop.as_ref()?; + if desktop.gateway_mode != ClaudeDesktopGatewayMode::Intercept || !desktop.use_llm_gateway { + return None; + } + let gateway = config.llm_gateway.as_ref()?; + let mut url = gateway + .intercept_url + .clone() + .unwrap_or_else(|| gateway.url.clone()); + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + Some(Wanted { gateway: url }) +} + +/// The intercept listener's state, for daemon-info. +#[derive(Debug)] +pub struct Status { + listen: SocketAddr, + state_dir: PathBuf, + inner: Mutex, +} + +#[derive(Debug, Default)] +struct StatusInner { + enabled: bool, + running: Option>, + error: Option, + ca_sha256: Option, +} + +impl Status { + pub fn new(listen: SocketAddr, state_dir: PathBuf) -> Arc { + Arc::new(Self { + listen, + state_dir, + inner: Mutex::default(), + }) + } + + fn update(&self, change: impl FnOnce(&mut StatusInner)) { + change(&mut self.inner.lock().unwrap_or_else(PoisonError::into_inner)); + } + + pub fn info(&self) -> InterceptInfo { + let inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner); + let health = inner + .running + .as_ref() + .map(|interceptor| interceptor.health().snapshot()) + .unwrap_or_default(); + let ca_certificate = store::certificate_path(&self.state_dir); + InterceptInfo { + listen: self.listen.to_string(), + enabled: inner.enabled, + bound: inner.running.is_some(), + error: inner.error.clone(), + ca_certificate: ca_certificate + .exists() + .then(|| ca_certificate.display().to_string()), + ca_sha256: inner.ca_sha256.clone(), + forwarded: health.forwarded, + gateway_failures: health.gateway_failures, + tunnels: health.tunnels, + tunnel_failures: health.tunnel_failures, + last_failure: health + .last_failure + .as_ref() + .map(|(_, message)| message.clone()), + last_failure_unix_seconds: health.last_failure.as_ref().map(|(at, _)| { + at.duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) + }), + } + } +} + +/// Run the intercept listener while the applied configuration asks for it. +/// It starts, stops and restarts as configurations arrive, and when it cannot +/// start (port taken, CA or credential store unavailable) it retries on its +/// own: Claude Desktop is pinned to this address and cannot connect meanwhile. +pub(crate) async fn supervise( + listen: SocketAddr, + state: api::AppState, + mut current: watch::Receiver>, + status: Arc, +) { + let mut running: Option<(Wanted, JoinHandle<()>)> = None; + loop { + let next = current + .borrow_and_update() + .as_ref() + .and_then(|current| wanted(¤t.config)); + if running.as_ref().map(|(wanted, _)| wanted) != next.as_ref() { + if let Some((_, task)) = running.take() { + task.abort(); + tracing::info!("intercept listener stopped for a configuration change"); + } + status.update(|inner| { + *inner = StatusInner { + enabled: next.is_some(), + ca_sha256: inner.ca_sha256.take(), + ..StatusInner::default() + }; + }); + if let Some(wanted) = next { + let task = tokio::spawn(run_listener( + listen, + wanted.clone(), + state.clone(), + status.clone(), + )); + running = Some((wanted, task)); + } + } + if current.changed().await.is_err() { + if let Some((_, task)) = running.take() { + task.abort(); + } + return; + } + } +} + +async fn run_listener( + listen: SocketAddr, + wanted: Wanted, + state: api::AppState, + status: Arc, +) { + loop { + match start(listen, &wanted, &state, &status).await { + Ok((listener, interceptor)) => { + tracing::info!(%listen, gateway = %wanted.gateway, "intercept listener serving"); + status.update(|inner| { + inner.running = Some(interceptor.clone()); + inner.error = None; + }); + let outcome = serve(listener, interceptor).await; + let message = match outcome { + Ok(()) => "the listener stopped".to_owned(), + Err(error) => format!("{error:#}"), + }; + tracing::error!(error = %message, "intercept listener stopped; restarting"); + status.update(|inner| { + inner.running = None; + inner.error = Some(message); + }); + } + Err(error) => { + let message = format!("{error:#}"); + tracing::error!(%listen, error = %message, "intercept listener could not start; retrying"); + status.update(|inner| inner.error = Some(message)); + } + } + tokio::time::sleep(RETRY_AFTER).await; + } +} + +async fn start( + listen: SocketAddr, + wanted: &Wanted, + state: &api::AppState, + status: &Status, +) -> anyhow::Result<(TcpListener, Arc)> { + let hosts: Vec = INTERCEPTED_HOSTS + .iter() + .map(|host| (*host).to_owned()) + .collect(); + let state_dir = state.state_dir.clone(); + let ca = { + let hosts = hosts.clone(); + tokio::task::spawn_blocking(move || store::load_or_create(&state_dir, &hosts)) + .await + .context("load the intercept CA")?? + }; + let fingerprint = store::fingerprint(ca.certificate_der()); + status.update(|inner| inner.ca_sha256 = Some(fingerprint)); + let interceptor = Interceptor::new( + InterceptConfig { + hosts, + gateway: gateway_target(&wanted.gateway)?, + idle_reset: None, + }, + ca, + Arc::new(DaemonCredentials { + state: state.clone(), + cached: tokio::sync::Mutex::default(), + }), + )?; + let listener = TcpListener::bind(listen) + .await + .with_context(|| format!("bind {listen}"))?; + Ok((listener, interceptor)) +} + +/// The gateway connection for `url` (scheme, host and port only). HTTPS +/// gateways are verified against the system's trusted roots. +fn gateway_target(url: &Url) -> anyhow::Result { + let host = url + .host_str() + .context("the gateway URL has no host")? + .trim_start_matches('[') + .trim_end_matches(']') + .to_owned(); + let port = url + .port_or_known_default() + .context("the gateway URL has no port")?; + match url.scheme() { + "http" => Ok(GatewayTarget::http(host, port)), + "https" => { + let mut roots = RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for error in &native.errors { + tracing::debug!(%error, "intercept: a system root certificate could not be loaded"); + } + let (added, _) = roots.add_parsable_certificates(native.certs); + if added == 0 { + anyhow::bail!("no trusted root certificates found for the gateway connection"); + } + GatewayTarget::https(host, port, roots) + } + scheme => anyhow::bail!("the gateway URL must use HTTP or HTTPS, got {scheme}"), + } +} + +/// Gateway credentials for the identity header, from the daemon's gateway +/// authentication: the controller-issued JWT, or the OIDC access token. +struct DaemonCredentials { + state: api::AppState, + /// The last credential; the lock also makes concurrent misses share one fetch. + cached: tokio::sync::Mutex>, +} + +struct Cached { + credential: String, + expires_at_unix_seconds: u64, + fetched: Instant, +} + +impl Cached { + fn usable(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + self.fetched.elapsed() < CREDENTIAL_REUSE + && self.expires_at_unix_seconds > now + CREDENTIAL_EXPIRY_MARGIN.as_secs() + } +} + +#[async_trait::async_trait] +impl CredentialSource for DaemonCredentials { + async fn credential(&self) -> Result, String> { + let mut cached = self.cached.lock().await; + if let Some(cached) = cached.as_ref() + && cached.usable() + { + return Ok(Some(cached.credential.clone())); + } + let effective = api::load_effective_config(&self.state.config, &self.state.state_dir) + .map_err(|error| format!("read the applied configuration: {error:#}"))?; + let gateway = effective + .llm_gateway + .as_ref() + .ok_or_else(|| "no LLM gateway is configured".to_owned())?; + if gateway.authentication.is_none() { + return Ok(None); + } + let fetched = tokio::time::timeout( + CREDENTIAL_FETCH_TIMEOUT, + api::gateway_credential(&self.state, &effective, CLIENT_ID, false), + ) + .await + .map_err(|_| "the gateway credential fetch did not finish in time".to_owned())? + .map_err(|(status, message)| format!("{message} ({status})"))?; + *cached = Some(Cached { + credential: fetched.credential.clone(), + expires_at_unix_seconds: fetched.expires_at_unix_seconds, + fetched: Instant::now(), + }); + Ok(Some(fetched.credential)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(yaml: &str) -> DaemonConfig { + agentdesktop_core::config::parse_daemon(yaml).unwrap() + } + + #[test] + fn the_listener_is_wanted_only_in_intercept_mode() { + assert_eq!( + wanted(&config( + "llmGateway:\n url: https://gateway.example.com/anthropic\nprograms:\n claudeDesktop: {}\n" + )), + None + ); + assert_eq!( + wanted(&config( + "llmGateway:\n url: https://gateway.example.com/anthropic\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n" + )) + .map(|wanted| wanted.gateway.to_string()), + Some("https://gateway.example.com/".to_owned()), + "the path of url is not used" + ); + assert_eq!( + wanted(&config( + "llmGateway:\n url: https://gateway.example.com/anthropic\n interceptUrl: http://127.0.0.1:4001\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n" + )) + .map(|wanted| wanted.gateway.to_string()), + Some("http://127.0.0.1:4001/".to_owned()) + ); + } + + #[test] + fn gateway_targets_follow_the_url_scheme() { + let plain = gateway_target(&"http://127.0.0.1:4001/".parse().unwrap()).unwrap(); + assert_eq!( + format!("{plain:?}"), + "GatewayTarget { host: \"127.0.0.1\", port: 4001, tls: false }" + ); + assert!(gateway_target(&"ftp://gateway.example.com/".parse().unwrap()).is_err()); + } + + #[test] + fn a_credential_is_reused_only_briefly_and_never_near_expiry() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let cached = |expires_at_unix_seconds, age| Cached { + credential: "jwt".to_owned(), + expires_at_unix_seconds, + fetched: Instant::now() - age, + }; + assert!(cached(now + 300, Duration::ZERO).usable()); + assert!(!cached(now + 10, Duration::ZERO).usable()); + assert!(!cached(now + 300, Duration::from_secs(61)).usable()); + } +} diff --git a/crates/agent/src/intercept/store.rs b/crates/agent/src/intercept/store.rs new file mode 100644 index 0000000..24bfa0b --- /dev/null +++ b/crates/agent/src/intercept/store.rs @@ -0,0 +1,153 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::Context; +use sha2::{Digest, Sha256}; + +use super::InterceptCa; +use crate::{secret_store::SecretStore, secure_fs}; + +const SECRET_SERVICE: &str = "dev.agentdesktop.intercept-ca"; +const SECRET_ACCOUNT: &str = "key"; +/// The CA certificate, for an administrator or an MDM profile to trust. +const CERTIFICATE_FILE: &str = "intercept-ca.pem"; +/// The hosts the stored CA was generated for. +const HOSTS_FILE: &str = "intercept-ca-hosts.json"; + +/// Where the intercept CA certificate is written. +pub fn certificate_path(state_dir: &Path) -> PathBuf { + state_dir.join(CERTIFICATE_FILE) +} + +/// The stored intercept CA for `hosts`, or a new one. The key is kept in the +/// daemon's secret store (the System keychain for the system daemon on macOS) +/// and the certificate in the state directory. A CA made for other hosts is +/// replaced, because its Name Constraints would not cover them. +pub(super) fn load_or_create( + state_dir: &Path, + hosts: &[String], +) -> anyhow::Result> { + let store = SecretStore::new(state_dir)?; + load_or_create_with( + state_dir, + hosts, + || store.get_optional(SECRET_SERVICE, SECRET_ACCOUNT), + |key| store.set(SECRET_SERVICE, SECRET_ACCOUNT, key), + ) +} + +fn load_or_create_with( + state_dir: &Path, + hosts: &[String], + read_key: impl FnOnce() -> anyhow::Result>, + write_key: impl FnOnce(&str) -> anyhow::Result<()>, +) -> anyhow::Result> { + let certificate_path = certificate_path(state_dir); + let hosts_path = state_dir.join(HOSTS_FILE); + let stored_hosts = std::fs::read(&hosts_path) + .ok() + .and_then(|contents| serde_json::from_slice::>(&contents).ok()); + let stored_certificate = std::fs::read_to_string(&certificate_path).ok(); + if let (Some(stored_hosts), Some(certificate)) = (&stored_hosts, &stored_certificate) + && stored_hosts == hosts + && let Some(key) = read_key().context("read the intercept CA key")? + { + match InterceptCa::from_pem(certificate, &key, hosts) { + Ok(ca) => return Ok(Arc::new(ca)), + Err(error) => tracing::warn!( + error = %format!("{error:#}"), + "the stored intercept CA could not be loaded; generating a new one" + ), + } + } + + let ca = InterceptCa::generate(&common_name(), hosts)?; + secure_fs::ensure_private_dir(state_dir)?; + // The key first: a certificate on disk whose key was never stored would be + // replaced at the next start anyway, but a stored key with no certificate + // costs nothing. + write_key(ca.key_pem()).context("store the intercept CA key")?; + secure_fs::atomic_write(&certificate_path, ca.certificate_pem().as_bytes(), 0o644) + .with_context(|| format!("write {}", certificate_path.display()))?; + secure_fs::atomic_write(&hosts_path, &serde_json::to_vec(hosts)?, 0o644) + .with_context(|| format!("write {}", hosts_path.display()))?; + tracing::warn!( + certificate = %certificate_path.display(), + sha256 = %fingerprint(ca.certificate_der()), + hosts = ?hosts, + "generated a new intercept CA: Claude Desktop refuses the intercepted hosts until this certificate is a trusted root on the device (System keychain or an MDM profile) and Claude Desktop restarts" + ); + Ok(Arc::new(ca)) +} + +/// The certificate's SHA-256 fingerprint, as `AB:CD:…`. +pub fn fingerprint(der: &[u8]) -> String { + Sha256::digest(der) + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(":") +} + +fn common_name() -> String { + let host = hostname::get() + .ok() + .and_then(|name| name.into_string().ok()) + .unwrap_or_else(|| "this device".to_owned()); + format!("agentdesktop intercept CA ({host})") +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + fn hosts(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + #[test] + fn the_ca_is_reused_for_the_same_hosts_and_replaced_for_others() { + let dir = tempfile::tempdir().unwrap(); + let key: RefCell> = RefCell::default(); + let load = |names: &[&str]| { + load_or_create_with( + dir.path(), + &hosts(names), + || Ok(key.borrow().clone()), + |value| { + *key.borrow_mut() = Some(value.to_owned()); + Ok(()) + }, + ) + .unwrap() + }; + let first = load(&["claude.ai", "api.anthropic.com"]); + let again = load(&["claude.ai", "api.anthropic.com"]); + assert_eq!(first.certificate_der(), again.certificate_der()); + let other = load(&["claude.ai"]); + assert_ne!(first.certificate_der(), other.certificate_der()); + assert_eq!( + std::fs::read_to_string(certificate_path(dir.path())).unwrap(), + other.certificate_pem() + ); + } + + #[test] + fn a_missing_key_replaces_the_ca() { + let dir = tempfile::tempdir().unwrap(); + let names = hosts(&["claude.ai"]); + let first = load_or_create_with(dir.path(), &names, || Ok(None), |_| Ok(())).unwrap(); + let second = load_or_create_with(dir.path(), &names, || Ok(None), |_| Ok(())).unwrap(); + assert_ne!(first.certificate_der(), second.certificate_der()); + } + + #[test] + fn fingerprints_are_colon_separated_hex() { + assert_eq!(fingerprint(b"").len(), 32 * 3 - 1); + assert!(fingerprint(b"x").starts_with("2D:71:16:42")); + } +} diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 393224a..2953d05 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -1409,10 +1409,11 @@ mod tests { daemon_info: DaemonInfo { version: "test".into(), scope: DaemonScope::User, config_path: String::new(), state_directory: String::new(), inventory_interval: Duration::from_secs(60), controller: None, llm_proxy: None, - usage_receiver: None }, + usage_receiver: None, intercept: None }, discovery, enrollment: EnrollmentState::new(false), state_dir: dir.path().to_owned(), oidc_callback_listen: None, telemetry: None, logout: None, usage: None, programs: None, + intercept: None, }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_address = listener.local_addr().unwrap(); @@ -2040,6 +2041,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(true), @@ -2049,6 +2051,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_address = listener.local_addr().unwrap(); @@ -2430,6 +2433,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2439,6 +2443,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; (dir, state) } @@ -2609,6 +2614,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2618,6 +2624,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; (dir, state) } @@ -2679,6 +2686,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2688,6 +2696,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; (dir, state) } diff --git a/crates/agent/src/provider/claude_code/mod.rs b/crates/agent/src/provider/claude_code/mod.rs index 0c01648..de85e3b 100644 --- a/crates/agent/src/provider/claude_code/mod.rs +++ b/crates/agent/src/provider/claude_code/mod.rs @@ -139,6 +139,7 @@ mod tests { let provider = ClaudeCode::default(); let context = ReconcileContext { llm_proxy: None, + intercept_proxy: None, merge_user_settings: false, credential_helper: PathBuf::from(r"C:\Program Files\Agent Desktop\agentdesktop.exe"), socket: PathBuf::from(r"\\.\pipe\agentdesktop"), diff --git a/crates/agent/src/provider/claude_desktop/mod.rs b/crates/agent/src/provider/claude_desktop/mod.rs index e6a09e5..e67f328 100644 --- a/crates/agent/src/provider/claude_desktop/mod.rs +++ b/crates/agent/src/provider/claude_desktop/mod.rs @@ -64,6 +64,7 @@ impl Provider for ClaudeDesktop { &self.credential_helper_path, &ctx.credential_helper, &ctx.socket, + ctx.intercept_proxy, configured, &plan, )?; diff --git a/crates/agent/src/provider/claude_desktop/reconcile.rs b/crates/agent/src/provider/claude_desktop/reconcile.rs index 7b2982c..8e15fc1 100644 --- a/crates/agent/src/provider/claude_desktop/reconcile.rs +++ b/crates/agent/src/provider/claude_desktop/reconcile.rs @@ -2,7 +2,9 @@ use super::ClaudeDesktop; use std::path::Path; -use agentdesktop_core::config::{ClaudeDesktopConfig, LlmGatewayAuthentication, LlmGatewayConfig}; +use agentdesktop_core::config::{ + ClaudeDesktopConfig, ClaudeDesktopGatewayMode, LlmGatewayAuthentication, LlmGatewayConfig, +}; use anyhow::Context; use serde_json::{Value, json}; use tracing::debug; @@ -15,12 +17,14 @@ use crate::provider::{json_merge, shared::deep_merge}; const OWNER_MARKER: &[u8] = b"Agentdesktop\n"; +#[allow(clippy::too_many_arguments)] pub(super) fn plan( settings_path: &Path, merge_existing: bool, helper_path: &Path, credential_binary: &Path, socket: &Path, + intercept_proxy: Option, config: Option<(&ClaudeDesktopConfig, Option<&LlmGatewayConfig>)>, plan: &ReconcilePlan, ) -> anyhow::Result<()> { @@ -44,12 +48,16 @@ pub(super) fn plan( return remove_owned(helper_path, &helper_owner, "credential helper", plan); }; - let uses_credential_helper = gateway.is_some_and(|gateway| { - gateway - .authentication - .as_ref() - .is_some_and(LlmGatewayAuthentication::uses_credential_helper) - }); + // In intercept mode Claude Desktop keeps its own claude.ai sign-in, so it + // gets no gateway credential: the listener adds the identity instead. + let intercept = config.gateway_mode == ClaudeDesktopGatewayMode::Intercept; + let uses_credential_helper = !intercept + && gateway.is_some_and(|gateway| { + gateway + .authentication + .as_ref() + .is_some_and(LlmGatewayAuthentication::uses_credential_helper) + }); if uses_credential_helper { let script = credential_helper_contents(credential_binary, socket)?; write_owned( @@ -64,7 +72,7 @@ pub(super) fn plan( remove_owned(helper_path, &helper_owner, "credential helper", plan)?; } - let settings = managed_settings(config, gateway, helper_path)?; + let settings = managed_settings(config, gateway, helper_path, intercept_proxy)?; if merge_existing { anyhow::bail!( "Claude Desktop settings cannot be applied to its user preferences; use system-managed settings" @@ -148,9 +156,23 @@ fn managed_settings( config: &ClaudeDesktopConfig, gateway: Option<&LlmGatewayConfig>, helper_path: &Path, + intercept_proxy: Option, ) -> anyhow::Result { let mut settings = serde_json::to_value(&config.settings) .context("serialize Claude Desktop pass-through settings")?; + if config.gateway_mode == ClaudeDesktopGatewayMode::Intercept { + // First party: no inference keys, so Claude Desktop stays signed in to + // claude.ai. The pinned proxy is the intercept listener; when it is not + // reachable, Claude Desktop fails instead of connecting directly. + let proxy = intercept_proxy.context( + "Claude Desktop gatewayMode intercept needs the system daemon's intercept listener", + )?; + deep_merge( + &mut settings, + json!({ "egressProxyUrl": format!("http://{proxy}") }), + ); + return Ok(settings); + } let Some(gateway) = gateway else { return Ok(settings); }; @@ -342,13 +364,103 @@ programs: ) .unwrap(); let desktop = config.programs.claude_desktop.as_ref().unwrap(); - let settings = - managed_settings(desktop, config.llm_gateway.as_ref(), Path::new("/helper")).unwrap(); + let settings = managed_settings( + desktop, + config.llm_gateway.as_ref(), + Path::new("/helper"), + None, + ) + .unwrap(); assert_eq!(settings["isLocalDevMcpEnabled"], true); assert_eq!(settings["inferenceProvider"], "gateway"); assert_eq!(settings["inferenceCredentialHelper"], "/helper"); } + const INTERCEPT_CONFIG: &str = r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [claude-desktop] +programs: + claudeDesktop: + gatewayMode: intercept + isLocalDevMcpEnabled: true +"#; + + #[test] + fn intercept_mode_pins_the_listener_and_writes_no_inference_keys() { + let config = parse_daemon(INTERCEPT_CONFIG).unwrap(); + let desktop = config.programs.claude_desktop.as_ref().unwrap(); + let settings = managed_settings( + desktop, + config.llm_gateway.as_ref(), + Path::new("/helper"), + Some("127.0.0.1:18471".parse().unwrap()), + ) + .unwrap(); + assert_eq!(settings["egressProxyUrl"], "http://127.0.0.1:18471"); + assert_eq!(settings["isLocalDevMcpEnabled"], true); + assert!( + settings + .as_object() + .unwrap() + .keys() + .all(|key| !key.starts_with("inference")), + "{settings}" + ); + assert!( + managed_settings( + desktop, + config.llm_gateway.as_ref(), + Path::new("/helper"), + None + ) + .is_err(), + "no listener address, no pin" + ); + } + + #[test] + #[cfg(unix)] + fn switching_to_intercept_mode_removes_the_credential_helper() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("managed-settings.json"); + let helper = dir.path().join("helper.sh"); + let apply = |yaml: &str| { + let config = parse_daemon(yaml).unwrap(); + let plan = crate::reconcile::ReconcilePlan::default(); + super::plan( + &settings_path, + false, + &helper, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some("127.0.0.1:18471".parse().unwrap()), + Some(( + config.programs.claude_desktop.as_ref().unwrap(), + config.llm_gateway.as_ref(), + )), + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + }; + apply(&INTERCEPT_CONFIG.replace(" gatewayMode: intercept\n", "")); + assert!(helper.exists(), "inference mode writes the helper"); + apply(INTERCEPT_CONFIG); + assert!(!helper.exists(), "intercept mode removes it"); + let bytes = std::fs::read(&settings_path).unwrap(); + // A property list on macOS, JSON elsewhere. + #[cfg(target_os = "macos")] + let written: serde_json::Value = plist::from_bytes(&bytes).unwrap(); + #[cfg(not(target_os = "macos"))] + let written: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(written["egressProxyUrl"], "http://127.0.0.1:18471"); + assert!(written.get("inferenceProvider").is_none()); + } + #[cfg(target_os = "macos")] #[test] fn macos_serializes_managed_settings_as_a_property_list() { @@ -366,8 +478,13 @@ programs: ) .unwrap(); let desktop = config.programs.claude_desktop.as_ref().unwrap(); - let settings = - managed_settings(desktop, config.llm_gateway.as_ref(), Path::new("/helper")).unwrap(); + let settings = managed_settings( + desktop, + config.llm_gateway.as_ref(), + Path::new("/helper"), + None, + ) + .unwrap(); let contents = serialize_managed_settings(&settings).expect("plist serializes"); assert!( @@ -417,6 +534,7 @@ programs: &helper, Path::new("agentdesktop"), Path::new("agentdesktop.sock"), + None, Some((config.programs.claude_desktop.as_ref().unwrap(), None)), plan, ) @@ -443,6 +561,7 @@ programs: &helper, Path::new("agentdesktop"), Path::new("agentdesktop.sock"), + None, Some((config.programs.claude_desktop.as_ref().unwrap(), None)), plan, ) diff --git a/crates/agent/src/provider/copilot/tests.rs b/crates/agent/src/provider/copilot/tests.rs index 4de39f1..d958641 100644 --- a/crates/agent/src/provider/copilot/tests.rs +++ b/crates/agent/src/provider/copilot/tests.rs @@ -50,6 +50,7 @@ fn sample_gateway() -> LlmGatewayConfig { url: "https://gateway.example.com".parse().unwrap(), authentication: None, proxy_url: None, + intercept_url: None, github_oauth: None, when_proxy_unavailable: Default::default(), } @@ -769,6 +770,7 @@ fn system_mode_rejects_a_configured_program_before_writing_anything() { credential_helper: dir.path().join("agentdesktop"), socket: dir.path().join("agentdesktop.sock"), llm_proxy: None, + intercept_proxy: None, }; // System mode: no providers path, or a path but no user settings. for provider in [ diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index b22310d..0c4de96 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -53,4 +53,9 @@ pub struct ReconcileContext { /// failed, or no pairing could be created: such programs must then remove /// or neutralise any pointer they wrote earlier, never leave a stale one. pub llm_proxy: Option, + /// The intercept listener address, which Claude Desktop is pinned to in + /// `gatewayMode: intercept`. Set in system mode whether or not the listener + /// is serving at the moment: Claude Desktop then fails to connect rather + /// than go around the gateway. `None` in user mode. + pub intercept_proxy: Option, } diff --git a/crates/agent/src/provider/vscode/settings_tests.rs b/crates/agent/src/provider/vscode/settings_tests.rs index a5df6b4..eaebf6b 100644 --- a/crates/agent/src/provider/vscode/settings_tests.rs +++ b/crates/agent/src/provider/vscode/settings_tests.rs @@ -59,6 +59,7 @@ fn gateway_with_proxy() -> LlmGatewayConfig { url: "https://gateway.example.com".parse().unwrap(), authentication: None, proxy_url: Some("https://gateway.example.com/copilot-proxy".parse().unwrap()), + intercept_url: None, github_oauth: None, when_proxy_unavailable: Default::default(), } @@ -67,6 +68,7 @@ fn gateway_with_proxy() -> LlmGatewayConfig { fn gateway_without_proxy_url() -> LlmGatewayConfig { LlmGatewayConfig { proxy_url: None, + intercept_url: None, ..gateway_with_proxy() } } diff --git a/crates/agent/src/provider/vscode/tests.rs b/crates/agent/src/provider/vscode/tests.rs index 91d37bb..1b722c6 100644 --- a/crates/agent/src/provider/vscode/tests.rs +++ b/crates/agent/src/provider/vscode/tests.rs @@ -39,6 +39,7 @@ fn sample_gateway() -> LlmGatewayConfig { url: "https://gateway.example.com".parse().unwrap(), authentication: None, proxy_url: None, + intercept_url: None, github_oauth: None, when_proxy_unavailable: Default::default(), } @@ -661,6 +662,7 @@ fn system_mode_rejects_a_configured_program_before_writing_anything() { credential_helper: dir.path().join("agentdesktop"), socket: dir.path().join("agentdesktop.sock"), llm_proxy: None, + intercept_proxy: None, }; // System mode: no chat models path, or a path but no user settings. for provider in [ @@ -715,6 +717,7 @@ fn github_models_through_the_provider_removes_the_chat_models_entry_and_writes_s address: "127.0.0.1:4000".parse().unwrap(), pairing: pairing.clone(), }), + intercept_proxy: None, }; let provider = VsCode { chat_models_path: Some(chat_models.clone()), diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index f4449fb..2addb86 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -73,6 +73,7 @@ impl Reconciler { credential_helper, socket, llm_proxy: None, + intercept_proxy: None, }, providers: Arc::new(vec![ Box::new(ClaudeCode { @@ -113,6 +114,13 @@ impl Reconciler { self } + /// The intercept listener address that Claude Desktop's `egressProxyUrl` + /// points at in intercept mode. + pub fn with_intercept(mut self, intercept_proxy: Option) -> Self { + self.context.intercept_proxy = intercept_proxy; + self + } + /// The device user's program choice. Every clone shares it. pub fn program_choice(&self) -> ProgramChoice { self.choice diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 2ac17f4..194bd61 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -98,6 +98,10 @@ pub struct DaemonStartupConfig { /// Local loopback LLM proxy. #[serde(default)] pub llm_proxy: LlmProxyStartupConfig, + /// Local intercept listener for Claude Desktop in first-party mode + /// (`programs.claudeDesktop.gatewayMode: intercept`). System mode only. + #[serde(default)] + pub intercept: InterceptStartupConfig, /// Usage collection without a controller, from a gateway on this machine. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, @@ -127,6 +131,24 @@ pub struct LlmProxyStartupConfig { pub client_id: Option, } +/// Intercept listener settings. The listener runs in the system daemon while the +/// applied configuration sets `programs.claudeDesktop.gatewayMode: intercept`. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InterceptStartupConfig { + /// Loopback address of the listener, which is also the proxy written into + /// Claude Desktop's `egressProxyUrl`. Defaults to `127.0.0.1:18471`. + /// Rejected in user mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, +} + +/// The intercept listener address when `daemon.intercept.listen` is unset. +pub fn default_intercept_listen() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 18471)) +} + /// Token and cost collection for a daemon without a controller. agentgateway /// on this machine exports its access logs (`frontendPolicies.accessLog.otlp`) /// to the daemon, which keeps this device's usage for the desktop app. @@ -269,6 +291,22 @@ pub struct LlmGatewayConfig { #[serde(rename = "proxyUrl", default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "schema", schemars(with = "Option"))] pub proxy_url: Option, + /// Where the intercept listener sends Claude Desktop's first-party traffic + /// (`programs.claudeDesktop.gatewayMode: intercept`), when it differs from + /// `url`. + /// + /// Only the scheme, host and port are used: each request keeps its own + /// path and its original host (`claude.ai`, `api.anthropic.com`), and the + /// gateway routes it by host. Without this setting the listener uses the + /// scheme, host and port of `url`. The URL cannot include a path, + /// credentials, a query, or a fragment. + #[serde( + rename = "interceptUrl", + default, + skip_serializing_if = "Option::is_none" + )] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] + pub intercept_url: Option, /// GitHub App OAuth used by the local proxy for the x-llm-token header. #[serde( rename = "githubOAuth", @@ -748,6 +786,12 @@ pub struct ClaudeDesktopConfig { /// Whether this program uses the top-level LLM gateway. #[serde(default = "default_true", skip_serializing_if = "is_true")] pub use_llm_gateway: bool, + /// How Claude Desktop uses the gateway. `inference` (the default) switches + /// it to third-party inference through the gateway. `intercept` keeps it + /// signed in to claude.ai and sends its Anthropic traffic through the + /// daemon's intercept listener, which forwards it to the gateway. + #[serde(default, skip_serializing_if = "ClaudeDesktopGatewayMode::is_default")] + pub gateway_mode: ClaudeDesktopGatewayMode, /// Upstream authentication used by this agent. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, @@ -756,6 +800,31 @@ pub struct ClaudeDesktopConfig { pub settings: BTreeMap, } +/// How Claude Desktop uses the LLM gateway. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ClaudeDesktopGatewayMode { + /// Third-party inference: Claude Desktop sends model requests to the + /// gateway with `inferenceProvider: gateway`. It does not sign in to + /// claude.ai, so claude.ai history, connectors and organization settings do + /// not apply. + #[default] + Inference, + /// First party: Claude Desktop stays signed in to claude.ai. The daemon + /// pins it to the local intercept listener with `egressProxyUrl`; the + /// listener decrypts claude.ai and api.anthropic.com with a local CA, + /// which must be trusted on the device, and forwards them to the gateway + /// with an `x-agentdesktop-identity` header. Every other host is tunnelled. + Intercept, +} + +impl ClaudeDesktopGatewayMode { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// Settings reconciled into Codex's organization-managed configuration. /// /// Values under `managedConfig` are written to Codex's `managed_config.toml`. @@ -1295,6 +1364,14 @@ fn parse_local_daemon(contents: &str) -> anyhow::Result { anyhow::bail!("daemon.usage.listen must be a loopback address"); } } + if config + .daemon + .as_ref() + .and_then(|daemon| daemon.intercept.listen) + .is_some_and(|listen| !listen.ip().is_loopback()) + { + anyhow::bail!("daemon.intercept.listen must be a loopback address"); + } validate_daemon( config.llm_gateway.as_ref(), config.sandbox.as_ref(), @@ -1368,6 +1445,28 @@ fn validate_daemon( anyhow::bail!("LLM gateway proxyUrl cannot include a query or fragment"); } } + if let Some(intercept_url) = &gateway.intercept_url { + if !matches!(intercept_url.scheme(), "http" | "https") { + anyhow::bail!( + "LLM gateway interceptUrl must use HTTP or HTTPS, got {}", + intercept_url.scheme() + ); + } + if intercept_url.host().is_none() { + anyhow::bail!("LLM gateway interceptUrl must include a host"); + } + if !intercept_url.username().is_empty() || intercept_url.password().is_some() { + anyhow::bail!("LLM gateway interceptUrl cannot include credentials"); + } + if intercept_url.query().is_some() || intercept_url.fragment().is_some() { + anyhow::bail!("LLM gateway interceptUrl cannot include a query or fragment"); + } + if intercept_url.path() != "/" { + anyhow::bail!( + "LLM gateway interceptUrl cannot include a path: requests keep their own path" + ); + } + } if let Some(github) = &gateway.github_oauth { match github.source { GitHubTokenSource::DeviceFlow => { @@ -1451,6 +1550,31 @@ fn validate_daemon( } } + if let Some(desktop) = &programs.claude_desktop + && desktop.gateway_mode == ClaudeDesktopGatewayMode::Intercept + { + if !desktop.use_llm_gateway || llm_gateway.is_none() { + anyhow::bail!( + "programs.claudeDesktop.gatewayMode intercept requires an llmGateway and useLlmGateway" + ); + } + if desktop.auth.is_some() { + anyhow::bail!( + "programs.claudeDesktop.auth cannot be combined with gatewayMode intercept: Claude Desktop keeps its own claude.ai sign-in" + ); + } + // A pass-through inference or proxy key would switch Claude Desktop + // back to third-party mode, or steer it past the listener. + if let Some(key) = desktop + .settings + .keys() + .find(|key| key.starts_with("inference") || key.starts_with("egressProxy")) + { + anyhow::bail!( + "programs.claudeDesktop.{key} cannot be set with gatewayMode intercept: the daemon manages Claude Desktop's gateway and proxy settings" + ); + } + } for (name, subscription, uses_gateway) in [ ( @@ -1584,8 +1708,11 @@ fn is_true(value: &bool) -> bool { #[cfg(test)] mod tests { + use std::net::SocketAddr; + use super::{ - DaemonConfig, LlmGatewayAuthentication, load_controller, parse_controller, parse_daemon, + ClaudeDesktopGatewayMode, DaemonConfig, LlmGatewayAuthentication, default_intercept_listen, + load_controller, parse_controller, parse_daemon, parse_local_daemon, }; #[test] @@ -2071,6 +2198,67 @@ llmGateway: assert!(error.to_string().contains("must use HTTP or HTTPS")); } + #[test] + fn accepts_claude_desktop_intercept_mode() { + let config = parse_local_daemon( + "daemon:\n intercept:\n listen: 127.0.0.1:18500\nllmGateway:\n url: https://gateway.example.com/anthropic\n interceptUrl: https://gateway.example.com:8443\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n isLocalDevMcpEnabled: true\n", + ) + .unwrap(); + let desktop = config.programs.claude_desktop.as_ref().unwrap(); + assert_eq!(desktop.gateway_mode, ClaudeDesktopGatewayMode::Intercept); + assert!(!desktop.settings.contains_key("gatewayMode")); + assert_eq!( + config.daemon.unwrap().intercept.listen, + Some("127.0.0.1:18500".parse().unwrap()) + ); + assert_eq!( + default_intercept_listen(), + "127.0.0.1:18471".parse::().unwrap() + ); + } + + #[test] + fn rejects_claude_desktop_intercept_mode_that_would_be_undone_or_cannot_work() { + let gateway = "llmGateway:\n url: https://gateway.example.com\n"; + for (yaml, message) in [ + ( + "programs:\n claudeDesktop:\n gatewayMode: intercept\n".to_owned(), + "requires an llmGateway", + ), + ( + format!("{gateway}programs:\n claudeDesktop:\n gatewayMode: intercept\n useLlmGateway: false\n"), + "requires an llmGateway", + ), + ( + format!("{gateway}programs:\n claudeDesktop:\n gatewayMode: intercept\n inferenceProvider: gateway\n"), + "programs.claudeDesktop.inferenceProvider cannot be set with gatewayMode intercept", + ), + ( + format!("{gateway}programs:\n claudeDesktop:\n gatewayMode: intercept\n egressProxyPacUrl: http://pac.example.com/p.pac\n"), + "programs.claudeDesktop.egressProxyPacUrl cannot be set", + ), + ( + format!("{gateway}programs:\n claudeDesktop:\n gatewayMode: intercept\n auth: subscription\n"), + "auth cannot be combined with gatewayMode intercept", + ), + ( + "llmGateway:\n url: https://gateway.example.com\n interceptUrl: https://gateway.example.com/anthropic\n".to_owned(), + "interceptUrl cannot include a path", + ), + ( + "llmGateway:\n url: https://gateway.example.com\n interceptUrl: ftp://gateway.example.com\n".to_owned(), + "interceptUrl must use HTTP or HTTPS", + ), + ( + "daemon:\n intercept:\n listen: 0.0.0.0:18471\n".to_owned(), + "daemon.intercept.listen must be a loopback address", + ), + ] { + let error = parse_local_daemon(&yaml).expect_err(&yaml); + assert!(error.to_string().contains(message), "{yaml}: {error}"); + } + } + #[test] fn rejects_an_invalid_llm_gateway_proxy_url() { for (proxy_url, message) in [ diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 20bd173..b18d891 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -141,6 +141,10 @@ pub struct DaemonInfo { /// `daemon.usage` is configured. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_receiver: Option, + /// Intercept listener for Claude Desktop in first-party mode. Present on a + /// system daemon; absent in user mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intercept: Option, } /// State of the daemon's receiver for gateway usage. @@ -158,6 +162,43 @@ pub struct UsageReceiverInfo { pub error: Option, } +/// State of the daemon's intercept listener for Claude Desktop in first-party +/// mode (`programs.claudeDesktop.gatewayMode: intercept`). +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InterceptInfo { + /// The listener address, which Claude Desktop's `egressProxyUrl` names. + pub listen: String, + /// Whether the applied configuration asks for the listener. + pub enabled: bool, + /// Whether the listener is serving. While it is enabled and not bound, + /// Claude Desktop cannot connect; the daemon retries on its own. + pub bound: bool, + /// Why the listener is not serving, when it is enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The intercept CA certificate, which must be a trusted root on the device. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_certificate: Option, + /// SHA-256 fingerprint of the intercept CA certificate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_sha256: Option, + /// Requests the gateway answered since the listener started. + pub forwarded: u64, + /// Requests that failed because the gateway or its credential was not available. + pub gateway_failures: u64, + /// Connections to other hosts, tunnelled without decryption. + pub tunnels: u64, + /// Tunnels that could not reach their destination. + pub tunnel_failures: u64, + /// The last failure, with its cause. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_failure: Option, + /// When the last failure happened. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_failure_unix_seconds: Option, +} + /// State of the daemon's loopback LLM proxy listener. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 91d8f06..7589d43 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -105,6 +105,10 @@ } ] }, + "gatewayMode": { + "description": "How Claude Desktop uses the gateway. `inference` (the default) switches\nit to third-party inference through the gateway. `intercept` keeps it\nsigned in to claude.ai and sends its Anthropic traffic through the\ndaemon's intercept listener, which forwards it to the gateway.", + "$ref": "#/$defs/ClaudeDesktopGatewayMode" + }, "useLlmGateway": { "description": "Whether this program uses the top-level LLM gateway.", "type": "boolean" @@ -112,6 +116,21 @@ }, "additionalProperties": true }, + "ClaudeDesktopGatewayMode": { + "description": "How Claude Desktop uses the LLM gateway.", + "oneOf": [ + { + "description": "Third-party inference: Claude Desktop sends model requests to the\ngateway with `inferenceProvider: gateway`. It does not sign in to\nclaude.ai, so claude.ai history, connectors and organization settings do\nnot apply.", + "type": "string", + "const": "inference" + }, + { + "description": "First party: Claude Desktop stays signed in to claude.ai. The daemon\npins it to the local intercept listener with `egressProxyUrl`; the\nlistener decrypts claude.ai and api.anthropic.com with a local CA,\nwhich must be trusted on the device, and forwards them to the gateway\nwith an `x-agentdesktop-identity` header. Every other host is tunnelled.", + "type": "string", + "const": "intercept" + } + ] + }, "ClaudeDesktopStartupConfig": { "description": "Local Claude Desktop paths.", "type": "object", @@ -260,6 +279,11 @@ "$ref": "#/$defs/ToolConfigPath", "default": {} }, + "intercept": { + "description": "Local intercept listener for Claude Desktop in first-party mode\n(`programs.claudeDesktop.gatewayMode: intercept`). System mode only.", + "$ref": "#/$defs/InterceptStartupConfig", + "default": {} + }, "llmProxy": { "description": "Local loopback LLM proxy.", "$ref": "#/$defs/LlmProxyStartupConfig", @@ -388,6 +412,20 @@ }, "additionalProperties": false }, + "InterceptStartupConfig": { + "description": "Intercept listener settings. The listener runs in the system daemon while the\napplied configuration sets `programs.claudeDesktop.gatewayMode: intercept`.", + "type": "object", + "properties": { + "listen": { + "description": "Loopback address of the listener, which is also the proxy written into\nClaude Desktop's `egressProxyUrl`. Defaults to `127.0.0.1:18471`.\nRejected in user mode.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "LlmGatewayAuthentication": { "description": "Authentication mechanisms supported by an LLM gateway.", "oneOf": [ @@ -496,6 +534,13 @@ } ] }, + "interceptUrl": { + "description": "Where the intercept listener sends Claude Desktop's first-party traffic\n(`programs.claudeDesktop.gatewayMode: intercept`), when it differs from\n`url`.\n\nOnly the scheme, host and port are used: each request keeps its own\npath and its original host (`claude.ai`, `api.anthropic.com`), and the\ngateway routes it by host. Without this setting the listener uses the\nscheme, host and port of `url`. The URL cannot include a path,\ncredentials, a query, or a fragment.", + "type": [ + "string", + "null" + ] + }, "proxyUrl": { "description": "Base URL the local LLM proxy forwards to, when it differs from `url`.\n\n`url` is shared by every program that sets `useLlmGateway`, so it can\nonly carry one path prefix. A gateway that puts each provider behind its\nown prefix therefore cannot serve both a program and the proxy from one\nvalue. Setting this leaves `url` to the programs and gives the proxy its\nown target. The same rules as for `url` apply.", "type": [ diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 957ce2e..137a8a0 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -19,6 +19,8 @@ |`daemon.copilot.config`|string|Configuration file.| |`daemon.grok`|object|Grok Build paths.| |`daemon.grok.config`|string|Configuration file.| +|`daemon.intercept`|object|Local intercept listener for Claude Desktop in first-party mode
(`programs.claudeDesktop.gatewayMode: intercept`). System mode only.| +|`daemon.intercept.listen`|string|Loopback address of the listener, which is also the proxy written into
Claude Desktop's `egressProxyUrl`. Defaults to `127.0.0.1:18471`.
Rejected in user mode.| |`daemon.llmProxy`|object|Local loopback LLM proxy.| |`daemon.llmProxy.clientId`|string|Credential policy client ID used by the proxy. Defaults to `vscode`.| |`daemon.llmProxy.listen`|string|Loopback address to listen on. Unset disables the proxy. Rejected in system mode.| @@ -52,6 +54,7 @@ |`llmGateway.githubOAuth`|object|GitHub App OAuth used by the local proxy for the x-llm-token header.| |`llmGateway.githubOAuth.clientId`|string|GitHub App client ID. Required when `source` is `deviceFlow`, where the
App must also enable Device Flow. Unused when `source` is `request`.| |`llmGateway.githubOAuth.source`|enum|Where the GitHub credential comes from.
Possible values: `deviceFlow`, `request`.| +|`llmGateway.interceptUrl`|string|Where the intercept listener sends Claude Desktop's first-party traffic
(`programs.claudeDesktop.gatewayMode: intercept`), when it differs from
`url`.

Only the scheme, host and port are used: each request keeps its own
path and its original host (`claude.ai`, `api.anthropic.com`), and the
gateway routes it by host. Without this setting the listener uses the
scheme, host and port of `url`. The URL cannot include a path,
credentials, a query, or a fragment.| |`llmGateway.proxyUrl`|string|Base URL the local LLM proxy forwards to, when it differs from `url`.

`url` is shared by every program that sets `useLlmGateway`, so it can
only carry one path prefix. A gateway that puts each provider behind its
own prefix therefore cannot serve both a program and the proxy from one
value. Setting this leaves `url` to the programs and gives the proxy its
own target. The same rules as for `url` apply.| |`llmGateway.url`|string|Base HTTP or HTTPS URL of the LLM gateway.

The URL must include a host and cannot include credentials, a query, or a fragment.| |`llmGateway.whenProxyUnavailable`|enum|What the programs behind the local LLM proxy (`programs.copilot`,
`programs.vscode`) do while the daemon runs without that proxy:
`failClosed` (the default) leaves their managed entries in place, so
the tools stay pointed at the loopback port and fail instead
of falling back to GitHub past the gateway (with no entries in place
yet, the tools keep their own settings); `failOpen` removes them
until the proxy is back (VS Code on `githubModels` then talks to GitHub
directly, the Copilot CLI and VS Code on `ownModels` lose the gateway's
models).
Possible values: `failClosed`, `failOpen`.| @@ -63,6 +66,7 @@ |`programs.claudeCode.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.claudeDesktop`|object|Claude Desktop managed configuration. Arbitrary keys are passed through directly.| |`programs.claudeDesktop.auth`|enum|Upstream authentication used by this agent.
Possible values: `subscription`.| +|`programs.claudeDesktop.gatewayMode`|enum|How Claude Desktop uses the gateway. `inference` (the default) switches
it to third-party inference through the gateway. `intercept` keeps it
signed in to claude.ai and sends its Anthropic traffic through the
daemon's intercept listener, which forwards it to the gateway.
Possible values: `inference`, `intercept`.| |`programs.claudeDesktop.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.codex`|object|Codex managed configuration.| |`programs.codex.managedConfig`|object|Arbitrary values written to Codex's organization-managed TOML configuration.

Use Codex's native snake_case configuration keys. TOML has no null value,
so null values cannot be reconciled.| From 2144d8c92eaed6d56fc2d8925d5eacd909dacc50 Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 14:43:13 -0400 Subject: [PATCH 3/9] test(agent): run the daemon in Claude Desktop intercept mode in the integration suite The new integration test runs the system daemon in a container with programs.claudeDesktop.gatewayMode set to intercept. curl stands in for Claude Desktop, because Claude Desktop has no Linux release. The test checks: - the managed settings pin egressProxyUrl and set nothing else; - daemon-info reports the listener and its CA; - requests to claude.ai and api.anthropic.com reach the gateway with their host and cookie, and without the identity header that the client sent; - other hosts are tunnelled; - with interceptUrl on a closed port, requests fail with 502 and daemon-info shows the cause; - the daemon reuses its stored CA after a restart; - turning the mode off removes the pin and the listener. The gateway fixture now also records the host, cookie and identity headers. --- .../claude_desktop/integration_tests.rs | 307 ++++++++++++++++++ .../claude_desktop/testdata/Dockerfile | 6 + crates/agent/tests/common/gateway.rs | 7 +- crates/agent/tests/integration.rs | 3 + 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 crates/agent/src/provider/claude_desktop/integration_tests.rs create mode 100644 crates/agent/src/provider/claude_desktop/testdata/Dockerfile diff --git a/crates/agent/src/provider/claude_desktop/integration_tests.rs b/crates/agent/src/provider/claude_desktop/integration_tests.rs new file mode 100644 index 0000000..f11e2aa --- /dev/null +++ b/crates/agent/src/provider/claude_desktop/integration_tests.rs @@ -0,0 +1,307 @@ +use std::time::Duration; + +use anyhow::{Context, ensure}; +use serde_json::{Value, json}; +use tokio::time::{sleep, timeout}; +use tracing::info; + +use crate::common::{Container, Gateway}; + +const CONFIG: &str = "/tmp/agentdesktop.yaml"; +const SETTINGS: &str = "/etc/claude-desktop/managed-settings.json"; +const OWNER: &str = "/etc/claude-desktop/.managed-settings.json.owner"; +const SOCKET: &str = "/run/agentdesktop/agentdesktop.sock"; +const CA: &str = "/var/lib/agentdesktop/intercept-ca.pem"; + +/// Claude Desktop has no Linux release, so curl stands in for it: it uses the +/// proxy that the daemon pins in the managed settings and trusts only the +/// daemon's intercept CA. +#[tokio::test] +async fn intercept_lifecycle() -> anyhow::Result<()> { + Container::run( + "claude-desktop", + "crates/agent/src/provider/claude_desktop/testdata/Dockerfile", + async |container| { + let gateway = Gateway::start().await?; + let gateway_address = gateway + .url + .trim_start_matches("http://") + .trim_end_matches('/') + .to_owned(); + // The container shares the host network, so a port that is free + // here is free for the daemon too. + let listen = format!("127.0.0.1:{}", free_port()?); + let proxy = format!("http://{listen}"); + container + .write( + CONFIG, + &config(&listen, json!({ "url": gateway.url }), true)?, + ) + .await?; + + info!("Checking dry run"); + let preview = container + .exec(&["agentdesktop", "daemon", "--config", CONFIG, "--dry-run"]) + .await?; + ensure!( + preview.contains("CREATE Claude Desktop"), + "unexpected preview: {preview}" + ); + container.exec(&["test", "!", "-e", SETTINGS]).await?; + container.exec(&["test", "!", "-e", CA]).await?; + + container + .start_process("daemon", &["agentdesktop", "daemon", "--config", CONFIG]) + .await?; + container.wait_ready(&["agentdesktop", "status"]).await?; + + info!("Checking the proxy pin and the listener"); + let managed = container.read(SETTINGS).await?; + let settings: Value = serde_json::from_str(&managed)?; + ensure!( + settings == json!({ "egressProxyUrl": proxy }), + "managed settings must pin the proxy and set nothing else: {settings}" + ); + ensure!( + container.read(OWNER).await? == "Agentdesktop\n", + "ownership marker missing" + ); + let status = wait_bound(container).await?; + ensure!( + status["enabled"] == true && status["listen"] == listen.as_str(), + "unexpected intercept status: {status}" + ); + ensure!( + status["caCertificate"] == CA, + "unexpected CA path: {status}" + ); + let fingerprint = status["caSha256"] + .as_str() + .context("daemon-info has no CA fingerprint")? + .to_owned(); + + info!("Sending intercepted and tunnelled requests through the listener"); + let body = through_listener( + container, + &proxy, + &[ + "--fail", + "-H", + "Cookie: sessionKey=integration", + "-H", + "x-agentdesktop-identity: Bearer spoofed", + "https://claude.ai/health", + ], + ) + .await?; + ensure!( + serde_json::from_str::(&body)?["ready"] == true, + "unexpected claude.ai response: {body}" + ); + through_listener( + container, + &proxy, + &["--fail", "--http1.1", "https://api.anthropic.com/health"], + ) + .await?; + through_listener( + container, + &proxy, + &[ + "--fail", + "--proxytunnel", + &format!("http://{gateway_address}/health"), + ], + ) + .await?; + let requests = gateway.requests(); + ensure!( + requests.iter().any(|r| { + r["path"] == "/health" + && r["host"] == "claude.ai" + && r["cookie"] == "sessionKey=integration" + && r["identity"].is_null() + }), + "the gateway did not get the claude.ai request with its cookie and without the client's identity header: {requests:?}" + ); + ensure!( + requests + .iter() + .any(|r| r["path"] == "/health" && r["host"] == "api.anthropic.com"), + "the gateway did not get the api.anthropic.com request: {requests:?}" + ); + ensure!( + requests + .iter() + .any(|r| r["path"] == "/health" && r["host"] == gateway_address.as_str()), + "the tunnelled request did not arrive: {requests:?}" + ); + let status = intercept_status(container).await?; + ensure!( + status["forwarded"].as_u64() >= Some(2) + && status["tunnels"].as_u64() >= Some(1) + && status["gatewayFailures"] == 0, + "unexpected intercept counters: {status}" + ); + + info!("Checking repeat reconciliation"); + let preview = container + .exec(&["agentdesktop", "daemon", "--config", CONFIG, "--dry-run"]) + .await?; + ensure!( + preview.contains("Summary: 0 changes"), + "repeat apply was not stable: {preview}" + ); + + // interceptUrl takes precedence over url. Pointing it at a closed + // port shows that requests fail at the listener and go nowhere else. + info!("Checking that requests fail when the gateway is down"); + container.stop_process("daemon").await?; + let closed = format!("http://127.0.0.1:{}/", free_port()?); + container + .write( + CONFIG, + &config( + &listen, + json!({ "url": gateway.url, "interceptUrl": closed }), + true, + )?, + ) + .await?; + container + .start_process( + "daemon-gateway-down", + &["agentdesktop", "daemon", "--config", CONFIG], + ) + .await?; + container.wait_ready(&["agentdesktop", "status"]).await?; + let status = wait_bound(container).await?; + ensure!( + status["caSha256"] == fingerprint.as_str(), + "the daemon did not reuse its stored CA: {status}" + ); + let before = gateway.requests().len(); + let code = through_listener( + container, + &proxy, + &["-o", "/dev/null", "-w", "%{http_code}", "https://claude.ai/health"], + ) + .await?; + ensure!(code == "502", "expected 502 from the listener, got {code}"); + let status = intercept_status(container).await?; + ensure!( + status["gatewayFailures"].as_u64() >= Some(1) + && status["lastFailure"] + .as_str() + .is_some_and(|failure| failure.contains("Connect")), + "daemon-info does not show the gateway failure: {status}" + ); + ensure!( + !gateway.requests()[before..] + .iter() + .any(|r| r["host"] == "claude.ai"), + "a request reached the gateway at url instead of failing" + ); + + info!("Checking that turning the mode off removes the pin and the listener"); + container.stop_process("daemon-gateway-down").await?; + container + .write( + CONFIG, + &config(&listen, json!({ "url": gateway.url }), false)?, + ) + .await?; + container + .start_process( + "daemon-off", + &["agentdesktop", "daemon", "--config", CONFIG], + ) + .await?; + container.wait_ready(&["agentdesktop", "status"]).await?; + container.exec(&["test", "!", "-e", SETTINGS]).await?; + container.exec(&["test", "!", "-e", OWNER]).await?; + let status = intercept_status(container).await?; + ensure!( + status["enabled"] == false && status["bound"] == false, + "the listener must not run without intercept mode: {status}" + ); + ensure!( + through_listener(container, &proxy, &["https://claude.ai/health"]) + .await + .is_err(), + "the listener still accepts connections" + ); + container.stop_process("daemon-off").await?; + Ok(()) + }, + ) + .await +} + +fn config(listen: &str, gateway: Value, intercept: bool) -> anyhow::Result { + let mut config = json!({ + "daemon": { "intercept": { "listen": listen } }, + "llmGateway": gateway, + "programs": {}, + }); + if intercept { + config["programs"]["claudeDesktop"] = json!({ "gatewayMode": "intercept" }); + } + Ok(serde_json::to_string_pretty(&config)?) +} + +fn free_port() -> anyhow::Result { + Ok(std::net::TcpListener::bind("127.0.0.1:0")? + .local_addr()? + .port()) +} + +async fn through_listener( + container: &Container, + proxy: &str, + args: &[&str], +) -> anyhow::Result { + let mut command = vec![ + "curl", + "-sS", + "--max-time", + "10", + "--proxy", + proxy, + "--cacert", + CA, + ]; + command.extend_from_slice(args); + container.exec(&command).await +} + +async fn intercept_status(container: &Container) -> anyhow::Result { + let info = container + .exec(&[ + "curl", + "-sS", + "--fail", + "--unix-socket", + SOCKET, + "http://localhost/v1/daemon-info", + ]) + .await?; + let info: Value = serde_json::from_str(&info).context("decode daemon-info")?; + Ok(info["intercept"].clone()) +} + +/// The listener starts after the first configuration is applied, so wait for it. +async fn wait_bound(container: &Container) -> anyhow::Result { + timeout(Duration::from_secs(30), async { + loop { + if let Ok(status) = intercept_status(container).await + && status["bound"] == true + { + return status; + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .context("the intercept listener did not start") +} diff --git a/crates/agent/src/provider/claude_desktop/testdata/Dockerfile b/crates/agent/src/provider/claude_desktop/testdata/Dockerfile new file mode 100644 index 0000000..b71bfd1 --- /dev/null +++ b/crates/agent/src/provider/claude_desktop/testdata/Dockerfile @@ -0,0 +1,6 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +# Claude Desktop has no Linux release, so this image runs only the daemon and +# curl, which stands in for Claude Desktop behind the intercept listener. +RUN groupadd --system agentdesktop +CMD ["sleep", "infinity"] diff --git a/crates/agent/tests/common/gateway.rs b/crates/agent/tests/common/gateway.rs index d4d1570..8b30c0f 100644 --- a/crates/agent/tests/common/gateway.rs +++ b/crates/agent/tests/common/gateway.rs @@ -73,8 +73,13 @@ async fn handle( let authorization = headers.get("authorization").and_then(|v| v.to_str().ok()); let api_key = headers.get("x-api-key").and_then(|v| v.to_str().ok()); let llm_token = headers.get("x-llm-token").and_then(|v| v.to_str().ok()); + let host = headers.get("host").and_then(|v| v.to_str().ok()); + let cookie = headers.get("cookie").and_then(|v| v.to_str().ok()); + let identity = headers + .get("x-agentdesktop-identity") + .and_then(|v| v.to_str().ok()); tracing::debug!(%method, path, "Gateway fixture request"); - requests.lock().unwrap().push(json!({"path": path, "method": method.as_str(), "authorization": authorization, "apiKey": api_key, "llmToken": llm_token, "body": body})); + requests.lock().unwrap().push(json!({"path": path, "method": method.as_str(), "host": host, "authorization": authorization, "apiKey": api_key, "llmToken": llm_token, "cookie": cookie, "identity": identity, "body": body})); if path == "/health" { return Json(json!({"ready": true})).into_response(); } diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 7896e6f..404c9b5 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -3,6 +3,9 @@ mod common; #[path = "../src/provider/claude_code/integration_tests.rs"] mod claude_code; +#[path = "../src/provider/claude_desktop/integration_tests.rs"] +mod claude_desktop; + #[path = "../src/provider/codex/integration_tests.rs"] mod codex; From a0cc08ea74c4ad9281ac6cd1cc960d7bc5c3b74d Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 15:01:00 -0400 Subject: [PATCH 4/9] test(agent): check the intercept identity header through a real agentgateway The new integration test runs the daemon in Claude Desktop intercept mode in front of agentgateway v1.6.0. jwtAuth in strict mode reads the token from x-agentdesktop-identity. A fake OIDC provider signs the daemon in with device authorization and issues ES256 tokens, and the test origin stands in for Anthropic. The test checks: - before sign-in, the listener returns 502, daemon-info shows that sign-in is required, and no request reaches the origin; - after sign-in, requests to claude.ai and api.anthropic.com pass agentgateway, and the origin gets the original host, Cookie and Authorization without the identity header; - agentgateway refuses a request with no identity or a forged one, and accepts one with a valid token. agentgateway needs `prefix: "Bearer "` on the header location, because the daemon sends `Bearer `. Without it every request gets a 401. --- Cargo.lock | 1 + crates/agent/Cargo.toml | 2 + .../claude_desktop/integration_tests.rs | 219 +++++++++++++++++- crates/agent/tests/common/agentgateway.rs | 67 ++++++ crates/agent/tests/common/issuer.rs | 181 +++++++++++++++ crates/agent/tests/common/mod.rs | 4 + 6 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 crates/agent/tests/common/agentgateway.rs create mode 100644 crates/agent/tests/common/issuer.rs diff --git a/Cargo.lock b/Cargo.lock index 9281586..dbe9a1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ dependencies = [ "anyhow", "apple-native-keyring-store", "async-trait", + "aws-lc-rs", "axum", "base64 0.23.1", "bytes", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 06648ce..020a263 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -62,6 +62,8 @@ toml.workspace = true url.workspace = true [dev-dependencies] +# Signs the fake identity provider's tokens in the integration tests. +aws-lc-rs.workspace = true futures-util = "0.3" # Hand-rolled RFC 6455 handshake for the vscode-copilot-capi WebSocket # tunnel tests (llm_proxy.rs): Sec-WebSocket-Accept is diff --git a/crates/agent/src/provider/claude_desktop/integration_tests.rs b/crates/agent/src/provider/claude_desktop/integration_tests.rs index f11e2aa..6b252fd 100644 --- a/crates/agent/src/provider/claude_desktop/integration_tests.rs +++ b/crates/agent/src/provider/claude_desktop/integration_tests.rs @@ -5,23 +5,24 @@ use serde_json::{Value, json}; use tokio::time::{sleep, timeout}; use tracing::info; -use crate::common::{Container, Gateway}; +use crate::common::{ + Agentgateway, Container, Gateway, Issuer, + issuer::{AUDIENCE, CLIENT_ID}, +}; const CONFIG: &str = "/tmp/agentdesktop.yaml"; const SETTINGS: &str = "/etc/claude-desktop/managed-settings.json"; const OWNER: &str = "/etc/claude-desktop/.managed-settings.json.owner"; const SOCKET: &str = "/run/agentdesktop/agentdesktop.sock"; const CA: &str = "/var/lib/agentdesktop/intercept-ca.pem"; +const DOCKERFILE: &str = "crates/agent/src/provider/claude_desktop/testdata/Dockerfile"; /// Claude Desktop has no Linux release, so curl stands in for it: it uses the /// proxy that the daemon pins in the managed settings and trusts only the /// daemon's intercept CA. #[tokio::test] async fn intercept_lifecycle() -> anyhow::Result<()> { - Container::run( - "claude-desktop", - "crates/agent/src/provider/claude_desktop/testdata/Dockerfile", - async |container| { + Container::run("claude-desktop", DOCKERFILE, async |container| { let gateway = Gateway::start().await?; let gateway_address = gateway .url @@ -238,6 +239,214 @@ async fn intercept_lifecycle() -> anyhow::Result<()> { .await } +/// The listener in front of a real agentgateway that requires the identity +/// header. A fake OIDC provider signs the daemon in with device +/// authorization, and the test origin stands in for Anthropic. +#[tokio::test] +async fn identity_through_agentgateway() -> anyhow::Result<()> { + Container::run("claude-desktop", DOCKERFILE, async |container| { + let origin = Gateway::start().await?; + let origin_address = origin + .url + .trim_start_matches("http://") + .trim_end_matches('/') + .to_owned(); + let issuer = Issuer::start().await?; + let gateway_port = free_port()?; + let agentgateway = Agentgateway::start( + &agentgateway_config(gateway_port, free_port()?, &origin_address, &issuer.url)?, + gateway_port, + ) + .await?; + let listen = format!("127.0.0.1:{}", free_port()?); + let proxy = format!("http://{listen}"); + container + .write( + CONFIG, + &config( + &listen, + json!({ + "url": format!("http://127.0.0.1:{gateway_port}/"), + "authentication": { + "type": "oidc", + "issuer": issuer.url, + "clientId": CLIENT_ID, + "allowInsecure": true, + "deviceAuthorization": true, + }, + }), + true, + )?, + ) + .await?; + container + .start_process("daemon", &["agentdesktop", "daemon", "--config", CONFIG]) + .await?; + container.wait_ready(&["agentdesktop", "status"]).await?; + wait_bound(container).await?; + + info!("Checking that requests fail until the user signs in"); + let login = container.exec(&["agentdesktop", "login"]).await?; + ensure!( + login.starts_with("awaitingAuthentication"), + "unexpected login status: {login}" + ); + let code = through_listener( + container, + &proxy, + &["-o", "/dev/null", "-w", "%{http_code}", "https://claude.ai/health"], + ) + .await?; + ensure!(code == "502", "expected 502 before sign-in, got {code}"); + let status = intercept_status(container).await?; + ensure!( + status["lastFailure"] + .as_str() + .is_some_and(|failure| failure.contains("sign-in required")), + "daemon-info does not show that sign-in is required: {status}" + ); + ensure!( + origin.requests().is_empty(), + "a request reached the origin before sign-in" + ); + + info!("Approving the sign-in"); + issuer.approve(); + container + .wait_ready(&["sh", "-c", "agentdesktop login | grep -qx signedIn"]) + .await?; + + info!("Sending requests through the listener and agentgateway"); + let body = through_listener( + container, + &proxy, + &[ + "--fail", + "-H", + "Cookie: sessionKey=integration", + "-H", + "x-agentdesktop-identity: Bearer spoofed", + "https://claude.ai/health", + ], + ) + .await?; + ensure!( + serde_json::from_str::(&body)?["ready"] == true, + "unexpected claude.ai response: {body}" + ); + through_listener( + container, + &proxy, + &[ + "--fail", + "--http1.1", + "-H", + "Authorization: Bearer client-token", + "https://api.anthropic.com/health", + ], + ) + .await?; + ensure!(issuer.issued() >= 1, "the daemon never got a token"); + let requests = origin.requests(); + ensure!( + requests.iter().any(|r| { + r["path"] == "/health" + && r["host"] == "claude.ai" + && r["cookie"] == "sessionKey=integration" + && r["identity"].is_null() + }), + "the origin did not get the claude.ai request with its cookie and without the identity header: {requests:?}" + ); + ensure!( + requests.iter().any(|r| { + r["host"] == "api.anthropic.com" + && r["authorization"] == "Bearer client-token" + && r["identity"].is_null() + }), + "the origin did not get the api.anthropic.com request with its own Authorization: {requests:?}" + ); + + info!("Checking that agentgateway refuses requests without a valid identity"); + let token = format!("Bearer {}", issuer.token()); + for (identity, expected) in [(None, "401"), (Some("Bearer spoofed"), "401"), (Some(token.as_str()), "200")] { + let code = direct(container, gateway_port, identity).await?; + ensure!( + code == expected, + "agentgateway answered {code} instead of {expected} for identity {identity:?}\n{}", + agentgateway.logs().await + ); + } + + container.stop_process("daemon").await?; + agentgateway.stop().await + }) + .await +} + +fn agentgateway_config( + port: u16, + admin_port: u16, + origin: &str, + issuer: &str, +) -> anyhow::Result { + // The daemon sends `x-agentdesktop-identity: Bearer `. jwtAuth + // removes the header after it validates the token. + let identity = json!({ + "mode": "strict", + "location": { "header": { "name": "x-agentdesktop-identity", "prefix": "Bearer " } }, + "issuer": issuer, + "audiences": [AUDIENCE], + "jwks": { "url": format!("{issuer}/keys") }, + }); + let route = |name: &str, host: &str| { + json!({ + "name": name, + "gateways": "intercept", + "hostnames": [host], + "policies": { "jwtAuth": identity }, + "backends": [{ "host": origin }], + }) + }; + Ok(serde_json::to_string_pretty(&json!({ + "config": { + "adminAddr": format!("127.0.0.1:{admin_port}"), + "statsAddr": "off", + "readinessAddr": "off", + }, + "gateways": { + "intercept": { "port": port, "bindAddress": "127.0.0.1", "protocol": "HTTP" }, + }, + "routes": [route("claude-ai", "claude.ai"), route("anthropic-api", "api.anthropic.com")], + }))?) +} + +/// A request for claude.ai straight to agentgateway, bypassing the listener. +async fn direct( + container: &Container, + gateway_port: u16, + identity: Option<&str>, +) -> anyhow::Result { + let url = format!("http://127.0.0.1:{gateway_port}/health"); + let header = identity.map(|identity| format!("x-agentdesktop-identity: {identity}")); + let mut command = vec![ + "curl", + "-sS", + "--max-time", + "10", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-H", + "Host: claude.ai", + ]; + if let Some(header) = &header { + command.extend(["-H", header.as_str()]); + } + command.push(&url); + container.exec(&command).await +} + fn config(listen: &str, gateway: Value, intercept: bool) -> anyhow::Result { let mut config = json!({ "daemon": { "intercept": { "listen": listen } }, diff --git a/crates/agent/tests/common/agentgateway.rs b/crates/agent/tests/common/agentgateway.rs new file mode 100644 index 0000000..d672d59 --- /dev/null +++ b/crates/agent/tests/common/agentgateway.rs @@ -0,0 +1,67 @@ +use std::time::{Duration, Instant}; + +use anyhow::Context; +use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; +use tokio::time::timeout; +use tracing::info; + +const IMAGE: &str = "cr.agentgateway.dev/agentgateway"; +const TAG: &str = "v1.6.0"; +const CONFIG_PATH: &str = "/etc/agentgateway/config.yaml"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(60); + +/// A real agentgateway on the host network, listening on `127.0.0.1:port`. +pub struct Agentgateway { + inner: ContainerAsync, +} + +impl Agentgateway { + /// Starts agentgateway with `config` and waits until `port` accepts + /// connections. + pub async fn start(config: &str, port: u16) -> anyhow::Result { + let started = Instant::now(); + let inner = GenericImage::new(IMAGE, TAG) + .with_cmd(["-f", CONFIG_PATH]) + .with_network("host") + .with_copy_to(CONFIG_PATH, config.as_bytes().to_vec()) + .with_startup_timeout(STARTUP_TIMEOUT) + .start() + .await + .context("start agentgateway (Docker is required)")?; + let gateway = Self { inner }; + let ready = timeout(STARTUP_TIMEOUT, async { + while tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_err() + { + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await; + if ready.is_err() { + anyhow::bail!( + "agentgateway did not listen on port {port}\n{}", + gateway.logs().await + ); + } + info!(elapsed = ?started.elapsed(), port, "Agentgateway ready"); + Ok(gateway) + } + + pub async fn logs(&self) -> String { + let stdout = self.inner.stdout_to_vec().await.unwrap_or_default(); + let stderr = self.inner.stderr_to_vec().await.unwrap_or_default(); + format!( + "{}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ) + } + + pub async fn stop(self) -> anyhow::Result<()> { + timeout(Duration::from_secs(30), self.inner.rm()) + .await + .context("agentgateway removal timed out")??; + Ok(()) + } +} diff --git a/crates/agent/tests/common/issuer.rs b/crates/agent/tests/common/issuer.rs new file mode 100644 index 0000000..9c9d398 --- /dev/null +++ b/crates/agent/tests/common/issuer.rs @@ -0,0 +1,181 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use aws_lc_rs::{ + rand::SystemRandom, + signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair}, +}; +use axum::{ + Json, Router, + body::Bytes, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +pub const CLIENT_ID: &str = "agentdesktop-integration"; +pub const AUDIENCE: &str = "agentgateway-integration"; +const KEY_ID: &str = "integration"; +const DEVICE_CODE: &str = "integration-device"; +const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// A fake OpenID Connect provider for the daemon's device sign-in. It holds +/// each sign-in until the test calls [`Issuer::approve`], then issues ES256 +/// access tokens that a gateway can check against its JWKS. +pub struct Issuer { + pub url: String, + state: Arc, + task: JoinHandle<()>, +} + +struct IssuerState { + url: String, + key: EcdsaKeyPair, + approved: AtomicBool, + issued: AtomicUsize, +} + +impl Issuer { + pub async fn start() -> anyhow::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://127.0.0.1:{}", listener.local_addr()?.port()); + let state = Arc::new(IssuerState { + url: url.clone(), + key: EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING) + .map_err(|_| anyhow::anyhow!("generate the issuer signing key"))?, + approved: AtomicBool::new(false), + issued: AtomicUsize::new(0), + }); + let app = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/device", post(device)) + .route("/token", post(token)) + .route("/keys", get(keys)) + .with_state(state.clone()); + let task = tokio::spawn(async move { + if let Err(error) = axum::serve(listener, app).await { + tracing::error!(%error, "Fixture issuer failed"); + } + }); + tracing::info!(%url, "Started host OIDC issuer fixture"); + Ok(Self { url, state, task }) + } + + /// Approves the pending device sign-in, as a user would in a browser. + pub fn approve(&self) { + self.state.approved.store(true, Ordering::SeqCst); + } + + /// The number of access tokens issued to the daemon. + pub fn issued(&self) -> usize { + self.state.issued.load(Ordering::SeqCst) + } + + /// A valid access token, issued directly to the test. + pub fn token(&self) -> String { + self.state.token() + } +} + +impl Drop for Issuer { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl IssuerState { + fn token(&self) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let header = json!({ "alg": "ES256", "typ": "JWT", "kid": KEY_ID }); + let claims = json!({ + "iss": self.url, "sub": "integration-user", "aud": AUDIENCE, + "iat": now, "exp": now + 3600, + }); + let signed = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + let signature = self + .key + .sign(&SystemRandom::new(), signed.as_bytes()) + .expect("sign the fixture token"); + format!("{signed}.{}", URL_SAFE_NO_PAD.encode(signature.as_ref())) + } +} + +async fn discovery(State(state): State>) -> Json { + let url = &state.url; + Json(json!({ + "issuer": url, + "authorization_endpoint": format!("{url}/authorize"), + "token_endpoint": format!("{url}/token"), + "device_authorization_endpoint": format!("{url}/device"), + "jwks_uri": format!("{url}/keys"), + })) +} + +async fn device(State(state): State>) -> Json { + Json(json!({ + "device_code": DEVICE_CODE, + "user_code": "TEST-CODE", + "verification_uri": format!("{}/activate", state.url), + "expires_in": 600, + "interval": 1, + })) +} + +async fn token(State(state): State>, body: Bytes) -> Response { + let form: Vec<(String, String)> = url::form_urlencoded::parse(&body).into_owned().collect(); + let field = |name: &str| { + form.iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + if field("grant_type") != Some(DEVICE_GRANT) + || field("device_code") != Some(DEVICE_CODE) + || field("client_id") != Some(CLIENT_ID) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid_grant" })), + ) + .into_response(); + } + if !state.approved.load(Ordering::SeqCst) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "authorization_pending" })), + ) + .into_response(); + } + state.issued.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "access_token": state.token(), + "token_type": "Bearer", + "expires_in": 3600, + })) + .into_response() +} + +async fn keys(State(state): State>) -> Json { + // An uncompressed P-256 point: 0x04, then x and y. + let point = state.key.public_key().as_ref(); + Json(json!({ "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", "kid": KEY_ID, + "x": URL_SAFE_NO_PAD.encode(&point[1..33]), + "y": URL_SAFE_NO_PAD.encode(&point[33..65]), + }] })) +} diff --git a/crates/agent/tests/common/mod.rs b/crates/agent/tests/common/mod.rs index 9517464..e835b5f 100644 --- a/crates/agent/tests/common/mod.rs +++ b/crates/agent/tests/common/mod.rs @@ -2,11 +2,15 @@ use std::{io::IsTerminal, sync::Once}; use tracing_subscriber::EnvFilter; +pub use agentgateway::Agentgateway; pub use container::Container; pub use gateway::{Gateway, TOKEN}; +pub use issuer::Issuer; +mod agentgateway; mod container; mod gateway; +pub mod issuer; fn setup_logging() { static INIT: Once = Once::new(); From c60f4a83de99852530862df96100066861064318 Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 15:04:05 -0400 Subject: [PATCH 5/9] ci: add a Claude Desktop intercept smoke test on macOS Run the real Claude Desktop app in first-party intercept mode on a GitHub-hosted macOS runner: build agentdesktop-headless, start agentgateway v1.6.0 with passthrough routes for claude.ai and api.anthropic.com, run the system daemon with programs.claudeDesktop.gatewayMode: intercept, trust its CA in the System keychain, then install Claude Desktop from its update feed (signature and team checked) and launch it without signing in. The job passes when main.log shows the pinned egress proxy and no ERR_CERT or ERR_SSL lines, agentgateway logs claude.ai answering with 2xx or 3xx, and daemon-info reports intercept.forwarded above zero. agentgateway logs Cloudflare's cf-mitigated response header, so a bot challenge from claude.ai fails with its own message. Triggered by workflow_dispatch and a weekly schedule only. --- .github/workflows/claude-desktop-smoke.yml | 269 +++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 .github/workflows/claude-desktop-smoke.yml diff --git a/.github/workflows/claude-desktop-smoke.yml b/.github/workflows/claude-desktop-smoke.yml new file mode 100644 index 0000000..f53acb9 --- /dev/null +++ b/.github/workflows/claude-desktop-smoke.yml @@ -0,0 +1,269 @@ +# Smoke test of Claude Desktop in first-party intercept mode on macOS, with the +# real Claude Desktop app. The system daemon pins Claude Desktop to its intercept +# listener (managed `egressProxyUrl`), its CA is trusted in the System keychain, +# and a local agentgateway passes claude.ai and api.anthropic.com through to +# Anthropic. Claude Desktop is launched without signing in, so the traffic is +# the signed-out start: the sign-in page and its API calls. +# +# Pass: Claude Desktop logs the pinned proxy and no certificate or TLS errors, +# agentgateway logs claude.ai answering with 2xx or 3xx, and the daemon counts +# forwarded requests. +name: Claude Desktop smoke + +on: + workflow_dispatch: + schedule: + - cron: "23 7 * * 1" + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CI: true + AGENTGATEWAY_VERSION: v1.6.0 + AGENTGATEWAY_SHA256: 6cebe8bd57262edce23a65a376d2f5a27b97cdfef609740642edbe75e1e9c685 + # Anthropic's Developer ID team for Claude Desktop. + CLAUDE_TEAM_ID: Q6L2SF6YDW + INTERCEPT_LISTEN: 127.0.0.1:18471 + GATEWAY_PORT: "4011" + DAEMON_ROOT: /var/tmp/agentdesktop-smoke + MANAGED_SETTINGS: /Library/Managed Preferences/com.anthropic.claudefordesktop.plist + +jobs: + smoke: + name: Claude Desktop intercept (macOS) + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set paths + run: | + smoke="$RUNNER_TEMP/claude-desktop-smoke" + mkdir -p "$smoke/out" + { + echo "SMOKE=$smoke" + echo "OUT=$smoke/out" + echo "AGW_LOG=$smoke/out/agentgateway.log" + echo "DAEMON_LOG=$smoke/out/daemon.log" + echo "STATE_DIR=$DAEMON_ROOT/state" + echo "SOCKET=$DAEMON_ROOT/agentdesktop.sock" + echo "MAIN_LOG=$HOME/Library/Logs/Claude/main.log" + } >>"$GITHUB_ENV" + + - name: Set up Rust build cache + uses: ./.github/actions/rust-build-cache + with: + cache-key: claude-desktop-smoke + save: ${{ github.ref == 'refs/heads/main' }} + + - name: Build the daemon + run: cargo build --locked -p agentdesktop-agent --bin agentdesktop-headless + + - name: Install agentgateway + run: | + curl -fsSL --retry 3 -o "$SMOKE/agentgateway" \ + "https://github.com/agentgateway/agentgateway/releases/download/$AGENTGATEWAY_VERSION/agentgateway-darwin-arm64" + echo "$AGENTGATEWAY_SHA256 $SMOKE/agentgateway" | shasum -a 256 -c + chmod +x "$SMOKE/agentgateway" + + # The latest release from the feed that Claude Desktop's own updater reads. + - name: Install Claude Desktop + run: | + feed=https://downloads.claude.ai/releases/darwin/universal/RELEASES.json + url=$(curl -fsSL --retry 3 "$feed" | jq -r '.currentRelease as $v | .releases[] | select(.version == $v) | .updateTo.url') + case "$url" in + https://downloads.claude.ai/releases/darwin/universal/*.zip) ;; + *) echo "::error::unexpected download URL in $feed: $url"; exit 1 ;; + esac + echo "Claude Desktop: $url" + curl -fsSL --retry 3 -o "$SMOKE/Claude.zip" "$url" + ditto -x -k "$SMOKE/Claude.zip" "$SMOKE/unpacked" + app="$SMOKE/unpacked/Claude.app" + codesign --verify --deep --strict "$app" + team=$(codesign -dv "$app" 2>&1 | sed -n 's/^TeamIdentifier=//p') + if [ "$team" != "$CLAUDE_TEAM_ID" ]; then + echo "::error::Claude.app is signed by team '$team', expected $CLAUDE_TEAM_ID" + exit 1 + fi + sudo mv "$app" /Applications/Claude.app + version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' /Applications/Claude.app/Contents/Info.plist) + echo "CLAUDE_VERSION=$version" >>"$GITHUB_ENV" + echo "Installed Claude Desktop $version" + + - name: Start agentgateway + run: | + cat >"$SMOKE/agentgateway.yaml" <"$AGW_LOG" 2>&1 & + for _ in $(seq 1 30); do nc -z 127.0.0.1 "$GATEWAY_PORT" 2>/dev/null && exit 0; sleep 1; done + echo "::error::agentgateway did not listen on port $GATEWAY_PORT" + cat "$AGW_LOG" + exit 1 + + - name: Start the system daemon + run: | + cat >"$SMOKE/daemon.yaml" <"$DAEMON_LOG" 2>&1 & + for _ in $(seq 1 60); do + sudo test -S "$SOCKET" && sudo test -f "$STATE_DIR/intercept-ca.pem" && test -f "$MANAGED_SETTINGS" && break + sleep 1 + done + if ! sudo test -f "$STATE_DIR/intercept-ca.pem" || ! test -f "$MANAGED_SETTINGS"; then + echo "::error::the daemon did not start its intercept listener or write Claude Desktop's managed settings" + cat "$DAEMON_LOG" + exit 1 + fi + sudo cat "$STATE_DIR/intercept-ca.pem" >"$OUT/intercept-ca.pem" + openssl x509 -in "$OUT/intercept-ca.pem" -noout -subject -fingerprint -sha256 -ext nameConstraints + plutil -p "$MANAGED_SETTINGS" + + - name: Trust the intercept CA + timeout-minutes: 3 + run: | + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$OUT/intercept-ca.pem" + security verify-cert -c "$OUT/intercept-ca.pem" + + - name: Launch Claude Desktop and check + run: | + pin="[egress-proxy] pinned to fixed proxy at $INTERCEPT_LISTEN" + check() { + pinned=no + grep -qF "$pin" "$MAIN_LOG" 2>/dev/null && pinned=yes + tls_errors=$(grep -cE 'ERR_CERT|ERR_SSL' "$MAIN_LOG" 2>/dev/null || true) + tls_errors=${tls_errors:-0} + claude_lines=$(grep -E 'route=default/claude-ai ' "$AGW_LOG" || true) + # Answers from claude.ai itself, not Cloudflare's challenge pages. + claude_ok=$(printf '%s\n' "$claude_lines" | grep -v 'http.path=/cdn-cgi/' | grep -v 'cf_mitigated=' \ + | grep -cE 'http.status=[23][0-9]{2} ' || true) + challenged=$(printf '%s\n' "$claude_lines" | grep -c 'cf_mitigated=' || true) + forwarded=$(sudo curl -sS --max-time 5 --unix-socket "$SOCKET" http://localhost/v1/daemon-info \ + | jq -r '.intercept.forwarded // 0' 2>/dev/null || echo 0) + forwarded=${forwarded:-0} + } + + open -a /Applications/Claude.app + for _ in $(seq 1 36); do + sleep 5 + check + [ "$pinned" = yes ] && [ "$claude_ok" -gt 0 ] && [ "$forwarded" -gt 0 ] && break + done + # Let the sign-in page finish loading, then check again. + sleep 20 + check + + echo "== agentgateway: host, status, Cloudflare mitigation" + grep -E 'route=default/(claude-ai|anthropic-api) ' "$AGW_LOG" \ + | sed -E 's/.*http\.host=([^ ]+).*http\.status=([0-9]+).*/\1 \2/' | sort | uniq -c | sort -rn || true + grep 'cf_mitigated=' "$AGW_LOG" \ + | sed -E 's/.*http\.host=([^ ]+) http\.path=([^ ?]+).*http\.status=([0-9]+).*cf_mitigated="([^"]*)".*/\1 \2 \3 \4/' \ + | sort | uniq -c | sort -rn || true + echo "== Claude Desktop TLS errors" + grep -oE 'net::ERR_(CERT|SSL)[A-Z_]*' "$MAIN_LOG" 2>/dev/null | sort | uniq -c || true + + { + echo "### Claude Desktop $CLAUDE_VERSION, intercept mode" + echo + echo "| Check | Result |" + echo "| --- | --- |" + echo "| \`main.log\` shows the pinned proxy | $pinned |" + echo "| \`ERR_CERT\` / \`ERR_SSL\` lines in \`main.log\` | $tls_errors |" + echo "| claude.ai 2xx/3xx through agentgateway | $claude_ok |" + echo "| claude.ai Cloudflare challenges | $challenged |" + echo "| daemon \`intercept.forwarded\` | $forwarded |" + } >>"$GITHUB_STEP_SUMMARY" + + failed=0 + [ "$pinned" = yes ] || { echo "::error::main.log does not show '$pin'"; failed=1; } + [ "$tls_errors" -eq 0 ] || { echo "::error::main.log has $tls_errors certificate or TLS errors"; failed=1; } + [ "$forwarded" -gt 0 ] || { echo "::error::the daemon forwarded no requests to agentgateway"; failed=1; } + if [ "$claude_ok" -eq 0 ]; then + if [ "$challenged" -gt 0 ]; then + echo "::error::claude.ai answered this runner with $challenged Cloudflare challenges and no regular responses" + else + echo "::error::agentgateway logged no 2xx or 3xx answers from claude.ai" + fi + failed=1 + fi + exit "$failed" + + - name: Collect logs + if: always() + run: | + screencapture -x "$OUT/screen.png" || true + sudo curl -sS --max-time 5 --unix-socket "$SOCKET" http://localhost/v1/daemon-info >"$OUT/daemon-info.json" || true + plutil -p "$MANAGED_SETTINGS" >"$OUT/managed-settings.txt" 2>&1 || true + pkill -x Claude || true + sleep 3 + cp -R "$HOME/Library/Logs/Claude" "$OUT/claude-logs" 2>/dev/null || true + sudo pkill -f agentdesktop-headless || true + pkill -f "$SMOKE/agentgateway" || true + + - name: Upload logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: claude-desktop-smoke + path: ${{ env.OUT }} + retention-days: 14 + if-no-files-found: warn From 73707ad096d27e4064026963da505c94fb1e9fd1 Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 15:51:26 -0400 Subject: [PATCH 6/9] ci: keep the system daemon's logs from the Claude Desktop smoke test --- .github/workflows/claude-desktop-smoke.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/claude-desktop-smoke.yml b/.github/workflows/claude-desktop-smoke.yml index f53acb9..3bf3e5c 100644 --- a/.github/workflows/claude-desktop-smoke.yml +++ b/.github/workflows/claude-desktop-smoke.yml @@ -258,6 +258,9 @@ jobs: cp -R "$HOME/Library/Logs/Claude" "$OUT/claude-logs" 2>/dev/null || true sudo pkill -f agentdesktop-headless || true pkill -f "$SMOKE/agentgateway" || true + # The system daemon logs to its state directory, not to stdout. + sudo cp -R "$STATE_DIR/logs" "$OUT/daemon-logs" || true + sudo chown -R "$(id -u)" "$OUT/daemon-logs" || true - name: Upload logs if: always() From 5d9e2b3c07c057ed53983b035b1a84a6198f2176 Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 18:02:34 -0400 Subject: [PATCH 7/9] fix(agent): harden the intercept listener's gateway, credentials and pin - Intercept mode requires an HTTPS gateway unless the gateway is on this device. The listener sends claude.ai cookies and tokens to it, so plain HTTP is allowed only for a loopback address, in validation and when the listener starts. - The listener fetches credentials for the configuration it started with, not the last saved one, so the identity always belongs to the gateway that requests go to. Any llmGateway change, such as a new issuer, restarts the listener and drops its cached credential. - A credential fetch runs on its own task, and only the wait for it times out. A token refresh that finishes late is still stored. - daemon --once refuses intercept mode, because nothing would serve the pinned listener after it exits. Dry runs, and cleanup of a Claude Desktop the user opted out of, still work. - daemon.intercept.listen refuses port 0, because Claude Desktop is pinned to that exact address. --- crates/agent/src/daemon.rs | 40 +++++++++ crates/agent/src/intercept/run.rs | 132 ++++++++++++++++++++++-------- crates/core/src/config.rs | 56 ++++++++++++- 3 files changed, 190 insertions(+), 38 deletions(-) diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index f06d4cf..693b719 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -1110,6 +1110,19 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow "--once cannot manage the VS Code chatLanguageModels.json and settings.json files because their local proxy requires the daemon to remain running" ); } + if config + .programs + .claude_desktop + .as_ref() + .is_some_and(|desktop| { + desktop.gateway_mode == agentdesktop_core::config::ClaudeDesktopGatewayMode::Intercept + && desktop.use_llm_gateway + }) + { + bail!( + "--once cannot pin Claude Desktop to the intercept listener because the listener requires the daemon to remain running" + ); + } let authenticated_gateway_is_used = config .llm_gateway .as_ref() @@ -2067,6 +2080,33 @@ programs: assert!(validate_one_shot(&config).is_err()); } + #[test] + fn one_shot_rejects_claude_desktop_intercept_mode_unless_opted_out() { + let config = parse_daemon( + r#" +programSelection: { allowUserChoice: true } +llmGateway: + url: https://gateway.example.com +programs: + claudeCode: {} + claudeDesktop: + gatewayMode: intercept +"#, + ) + .unwrap(); + assert!( + validate_one_shot(&config) + .unwrap_err() + .to_string() + .contains("intercept listener") + ); + let only_claude_code = agentdesktop_core::model::ProgramChoice::Selected { + programs: ["claude-code".to_owned()].into(), + }; + let chosen = crate::reconcile::apply_program_choice(&config, &only_claude_code).0; + validate_one_shot(&chosen).expect("an opted-out Claude Desktop is only cleaned up"); + } + #[test] fn one_shot_rejects_the_vscode_program() { let vscode = parse_daemon( diff --git a/crates/agent/src/intercept/run.rs b/crates/agent/src/intercept/run.rs index 95fe23c..4d18cdd 100644 --- a/crates/agent/src/intercept/run.rs +++ b/crates/agent/src/intercept/run.rs @@ -6,7 +6,7 @@ use std::{ }; use agentdesktop_core::{ - config::{ClaudeDesktopGatewayMode, DaemonConfig}, + config::{ClaudeDesktopGatewayMode, DaemonConfig, is_loopback_url}, model::InterceptInfo, }; use anyhow::Context; @@ -22,7 +22,8 @@ use crate::{api, tick::CurrentConfig}; /// The credential policy client id the identity header is issued for: the same /// one Claude Desktop's credential helper uses in inference mode. const CLIENT_ID: &str = "claude-desktop"; -/// How long a request waits for a credential fetch before it fails. +/// How long a request waits for a credential fetch before it fails. The fetch +/// itself goes on, so a token refresh it started is still stored. const CREDENTIAL_FETCH_TIMEOUT: Duration = Duration::from_secs(20); /// A cached credential is not used within this margin of its expiry. const CREDENTIAL_EXPIRY_MARGIN: Duration = Duration::from_secs(30); @@ -32,16 +33,32 @@ const CREDENTIAL_REUSE: Duration = Duration::from_secs(60); /// Wait before trying again when the listener could not start. const RETRY_AFTER: Duration = Duration::from_secs(10); -/// What the listener needs from the applied configuration. A change restarts it. -#[derive(Clone, Debug, PartialEq, Eq)] +/// What the listener needs from the applied configuration. A change to the +/// gateway or to any `llmGateway` setting, such as its authentication, +/// restarts it, which also drops its cached credential. +#[derive(Clone, Debug)] pub(crate) struct Wanted { /// The gateway's scheme, host and port. gateway: Url, + /// The `llmGateway` settings, compared to detect a change. + settings: serde_json::Value, + /// The configuration the listener started with. Credentials are fetched + /// for it, so the identity always belongs to the gateway that requests go + /// to, even before the configuration is applied and saved. + config: Arc, } +impl PartialEq for Wanted { + fn eq(&self, other: &Self) -> bool { + self.gateway == other.gateway && self.settings == other.settings + } +} + +impl Eq for Wanted {} + /// The listener settings `config` asks for, or `None` when Claude Desktop is not /// in intercept mode. -pub(crate) fn wanted(config: &DaemonConfig) -> Option { +pub(crate) fn wanted(config: &Arc) -> Option { let desktop = config.programs.claude_desktop.as_ref()?; if desktop.gateway_mode != ClaudeDesktopGatewayMode::Intercept || !desktop.use_llm_gateway { return None; @@ -54,7 +71,11 @@ pub(crate) fn wanted(config: &DaemonConfig) -> Option { url.set_path("/"); url.set_query(None); url.set_fragment(None); - Some(Wanted { gateway: url }) + Some(Wanted { + gateway: url, + settings: serde_json::to_value(gateway).unwrap_or_default(), + config: config.clone(), + }) } /// The intercept listener's state, for daemon-info. @@ -229,7 +250,8 @@ async fn start( ca, Arc::new(DaemonCredentials { state: state.clone(), - cached: tokio::sync::Mutex::default(), + config: wanted.config.clone(), + cached: Arc::default(), }), )?; let listener = TcpListener::bind(listen) @@ -239,7 +261,9 @@ async fn start( } /// The gateway connection for `url` (scheme, host and port only). HTTPS -/// gateways are verified against the system's trusted roots. +/// gateways are verified against the system's trusted roots. Plain HTTP is +/// for a gateway on this device only: requests carry claude.ai cookies and +/// tokens. Configuration validation refuses it too. fn gateway_target(url: &Url) -> anyhow::Result { let host = url .host_str() @@ -251,7 +275,8 @@ fn gateway_target(url: &Url) -> anyhow::Result { .port_or_known_default() .context("the gateway URL has no port")?; match url.scheme() { - "http" => Ok(GatewayTarget::http(host, port)), + "http" if is_loopback_url(url) => Ok(GatewayTarget::http(host, port)), + "http" => anyhow::bail!("a gateway that is not on this device must use HTTPS"), "https" => { let mut roots = RootCertStore::empty(); let native = rustls_native_certs::load_native_certs(); @@ -272,8 +297,10 @@ fn gateway_target(url: &Url) -> anyhow::Result { /// authentication: the controller-issued JWT, or the OIDC access token. struct DaemonCredentials { state: api::AppState, + /// The configuration the listener started with. + config: Arc, /// The last credential; the lock also makes concurrent misses share one fetch. - cached: tokio::sync::Mutex>, + cached: Arc>>, } struct Cached { @@ -295,34 +322,45 @@ impl Cached { #[async_trait::async_trait] impl CredentialSource for DaemonCredentials { async fn credential(&self) -> Result, String> { - let mut cached = self.cached.lock().await; - if let Some(cached) = cached.as_ref() - && cached.usable() - { - return Ok(Some(cached.credential.clone())); - } - let effective = api::load_effective_config(&self.state.config, &self.state.state_dir) - .map_err(|error| format!("read the applied configuration: {error:#}"))?; - let gateway = effective + let gateway = self + .config .llm_gateway .as_ref() .ok_or_else(|| "no LLM gateway is configured".to_owned())?; if gateway.authentication.is_none() { return Ok(None); } - let fetched = tokio::time::timeout( - CREDENTIAL_FETCH_TIMEOUT, - api::gateway_credential(&self.state, &effective, CLIENT_ID, false), - ) - .await - .map_err(|_| "the gateway credential fetch did not finish in time".to_owned())? - .map_err(|(status, message)| format!("{message} ({status})"))?; - *cached = Some(Cached { - credential: fetched.credential.clone(), - expires_at_unix_seconds: fetched.expires_at_unix_seconds, - fetched: Instant::now(), - }); - Ok(Some(fetched.credential)) + // The fetch runs on its own task so the wait below does not cancel it + // half-way: a token refresh must finish and be stored. The task holds + // the cache lock, so of concurrent misses one fetches and the others + // find its credential. + let fetch = { + let state = self.state.clone(); + let config = self.config.clone(); + let cached = self.cached.clone(); + tokio::spawn(async move { + let mut cached = cached.lock().await; + if let Some(cached) = cached.as_ref() + && cached.usable() + { + return Ok(cached.credential.clone()); + } + let fetched = api::gateway_credential(&state, &config, CLIENT_ID, false) + .await + .map_err(|(status, message)| format!("{message} ({status})"))?; + *cached = Some(Cached { + credential: fetched.credential.clone(), + expires_at_unix_seconds: fetched.expires_at_unix_seconds, + fetched: Instant::now(), + }); + Ok(fetched.credential) + }) + }; + tokio::time::timeout(CREDENTIAL_FETCH_TIMEOUT, fetch) + .await + .map_err(|_| "the gateway credential fetch did not finish in time".to_owned())? + .map_err(|error| format!("the gateway credential fetch failed: {error}"))? + .map(Some) } } @@ -330,8 +368,8 @@ impl CredentialSource for DaemonCredentials { mod tests { use super::*; - fn config(yaml: &str) -> DaemonConfig { - agentdesktop_core::config::parse_daemon(yaml).unwrap() + fn config(yaml: &str) -> Arc { + Arc::new(agentdesktop_core::config::parse_daemon(yaml).unwrap()) } #[test] @@ -359,6 +397,30 @@ mod tests { ); } + #[test] + fn any_gateway_setting_change_restarts_the_listener() { + let base = "llmGateway:\n url: https://gateway.example.com\n"; + let mode = "programs:\n claudeDesktop:\n gatewayMode: intercept\n"; + let oidc = |issuer: &str| { + format!( + "{base} authentication:\n type: oidc\n issuer: {issuer}\n clientId: agentdesktop\n{mode}" + ) + }; + let first = wanted(&config(&oidc("https://idp.example.com"))).unwrap(); + let same = wanted(&config(&format!( + "{} claudeCode: {{}}\n", + oidc("https://idp.example.com") + ))) + .unwrap(); + let other_issuer = wanted(&config(&oidc("https://other-idp.example.com"))).unwrap(); + assert_eq!( + first, same, + "a change outside llmGateway keeps the listener" + ); + assert_ne!(first, other_issuer); + assert_eq!(first.gateway, other_issuer.gateway); + } + #[test] fn gateway_targets_follow_the_url_scheme() { let plain = gateway_target(&"http://127.0.0.1:4001/".parse().unwrap()).unwrap(); @@ -367,6 +429,8 @@ mod tests { "GatewayTarget { host: \"127.0.0.1\", port: 4001, tls: false }" ); assert!(gateway_target(&"ftp://gateway.example.com/".parse().unwrap()).is_err()); + assert!(gateway_target(&"http://localhost:4001/".parse().unwrap()).is_ok()); + assert!(gateway_target(&"http://gateway.example.com/".parse().unwrap()).is_err()); } #[test] diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 194bd61..e9eb9d0 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -1364,13 +1364,20 @@ fn parse_local_daemon(contents: &str) -> anyhow::Result { anyhow::bail!("daemon.usage.listen must be a loopback address"); } } - if config + if let Some(listen) = config .daemon .as_ref() .and_then(|daemon| daemon.intercept.listen) - .is_some_and(|listen| !listen.ip().is_loopback()) { - anyhow::bail!("daemon.intercept.listen must be a loopback address"); + if !listen.ip().is_loopback() { + anyhow::bail!("daemon.intercept.listen must be a loopback address"); + } + // Claude Desktop is pinned to this exact address. + if listen.port() == 0 { + anyhow::bail!( + "daemon.intercept.listen needs a fixed port: Claude Desktop is pinned to this address" + ); + } } validate_daemon( config.llm_gateway.as_ref(), @@ -1553,10 +1560,18 @@ fn validate_daemon( if let Some(desktop) = &programs.claude_desktop && desktop.gateway_mode == ClaudeDesktopGatewayMode::Intercept { - if !desktop.use_llm_gateway || llm_gateway.is_none() { + let Some(gateway) = llm_gateway.filter(|_| desktop.use_llm_gateway) else { anyhow::bail!( "programs.claudeDesktop.gatewayMode intercept requires an llmGateway and useLlmGateway" ); + }; + // The listener sends decrypted claude.ai cookies and tokens to the + // gateway, so only a gateway on this device may use plain HTTP. + let target = gateway.intercept_url.as_ref().unwrap_or(&gateway.url); + if target.scheme() != "https" && !is_loopback_url(target) { + anyhow::bail!( + "programs.claudeDesktop.gatewayMode intercept requires an HTTPS gateway: llmGateway.interceptUrl, or url when it is unset, may use HTTP only on a loopback address" + ); } if desktop.auth.is_some() { anyhow::bail!( @@ -1689,6 +1704,16 @@ fn is_loopback(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") } +/// Returns whether `url` names this device: `localhost` or a loopback address. +pub fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + /// Returns whether a caller-provided LLM gateway client identifier is valid. pub fn valid_client_id(client_id: &str) -> bool { !client_id.is_empty() @@ -2215,6 +2240,17 @@ llmGateway: default_intercept_listen(), "127.0.0.1:18471".parse::().unwrap() ); + // A gateway on this device may use plain HTTP. + for gateway in [ + "url: http://127.0.0.1:4011", + "url: https://gateway.example.com\n interceptUrl: http://localhost:4011", + "url: https://gateway.example.com\n interceptUrl: http://[::1]:4011", + ] { + let yaml = format!( + "llmGateway:\n {gateway}\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n" + ); + parse_local_daemon(&yaml).expect(&yaml); + } } #[test] @@ -2253,6 +2289,18 @@ llmGateway: "daemon:\n intercept:\n listen: 0.0.0.0:18471\n".to_owned(), "daemon.intercept.listen must be a loopback address", ), + ( + "daemon:\n intercept:\n listen: 127.0.0.1:0\n".to_owned(), + "daemon.intercept.listen needs a fixed port", + ), + ( + "llmGateway:\n url: http://gateway.example.com\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n".to_owned(), + "requires an HTTPS gateway", + ), + ( + "llmGateway:\n url: https://gateway.example.com\n interceptUrl: http://10.0.0.5:4001\nprograms:\n claudeDesktop:\n gatewayMode: intercept\n".to_owned(), + "requires an HTTPS gateway", + ), ] { let error = parse_local_daemon(&yaml).expect_err(&yaml); assert!(error.to_string().contains(message), "{yaml}: {error}"); From 3df51296bccc95c68cc467e866c8376f6431533a Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 22 Oct 2026 18:21:10 -0400 Subject: [PATCH 8/9] test(agent): run the intercept walkthrough as an integration test The walkthrough now lives in crates/agent/tests/intercept.rs, its own test target, and uses only the public intercept API. It still needs no Docker, keychain or admin rights. - The Name Constraints check needs leaves that the listener never mints, so it moved to a unit test in intercept/ca.rs and checks the verifier directly. - The Windows CI job runs the new target as well as the library tests, so the walkthrough keeps running on Windows. - tests/README.md says how to run it. --- .github/workflows/ci.yml | 2 +- crates/agent/Cargo.toml | 4 + crates/agent/src/intercept/ca.rs | 46 ++++++++++- crates/agent/src/intercept/mod.rs | 2 - crates/agent/tests/README.md | 11 +++ .../walkthrough.rs => tests/intercept.rs} | 78 ++++--------------- 6 files changed, 74 insertions(+), 69 deletions(-) rename crates/agent/{src/intercept/walkthrough.rs => tests/intercept.rs} (90%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324e925..c032bb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: tool: nextest - name: Test agent - run: cargo nextest run --locked --package agentdesktop-agent --lib + run: cargo nextest run --locked --package agentdesktop-agent --lib --test intercept container: name: Container diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 020a263..583595e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -15,6 +15,10 @@ path = "src/bin/agentdesktop-headless.rs" name = "integration" path = "tests/integration.rs" +[[test]] +name = "intercept" +path = "tests/intercept.rs" + [[bin]] name = "agentdesktop-service" path = "src/bin/agentdesktop-service.rs" diff --git a/crates/agent/src/intercept/ca.rs b/crates/agent/src/intercept/ca.rs index 679ed41..9269db9 100644 --- a/crates/agent/src/intercept/ca.rs +++ b/crates/agent/src/intercept/ca.rs @@ -166,8 +166,8 @@ impl InterceptCa { Ok(key) } - /// A leaf for any name, without the host check: the walkthrough test uses - /// it to show that a verifier rejects names outside the constraints. + /// A leaf for any name, without the host check: a test uses it to show + /// that a verifier rejects names outside the constraints. #[cfg(test)] pub(super) fn leaf_unchecked(&self, host: &str) -> anyhow::Result> { Ok(self.mint(host, SystemTime::now())?.key) @@ -237,4 +237,46 @@ mod tests { fn an_empty_host_list_is_refused() { assert!(InterceptCa::generate("test intercept CA", &[]).is_err()); } + + /// Even a leaf the listener would never mint is rejected by a verifier + /// that enforces Name Constraints on the trust anchor. This is + /// rustls-webpki; Electron's verifier and Apple's SecTrust are not tested. + #[test] + fn a_verifier_rejects_names_outside_the_name_constraints() { + use rustls::{ + RootCertStore, + client::{WebPkiServerVerifier, danger::ServerCertVerifier}, + pki_types::{ServerName, UnixTime}, + }; + + let ca = InterceptCa::generate("test intercept CA", &hosts()).unwrap(); + let mut roots = RootCertStore::empty(); + roots.add(ca.certificate_der().clone()).unwrap(); + let verifier = WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::aws_lc_rs::default_provider()), + ) + .build() + .unwrap(); + for (name, leaf, accepted) in [ + ("claude.ai", ca.leaf("claude.ai"), true), + ("example.com", ca.leaf_unchecked("example.com"), false), + ("127.0.0.1", ca.leaf_unchecked("127.0.0.1"), false), + ] { + let leaf = leaf.unwrap(); + let outcome = verifier.verify_server_cert( + &leaf.cert[0], + &[], + &ServerName::try_from(name).unwrap(), + &[], + UnixTime::now(), + ); + if accepted { + assert!(outcome.is_ok(), "{name}: {outcome:?}"); + } else { + let error = format!("{:?}", outcome.unwrap_err()); + assert!(error.contains("NameConstraint"), "{name}: {error}"); + } + } + } } diff --git a/crates/agent/src/intercept/mod.rs b/crates/agent/src/intercept/mod.rs index 31794e4..1f4d826 100644 --- a/crates/agent/src/intercept/mod.rs +++ b/crates/agent/src/intercept/mod.rs @@ -39,8 +39,6 @@ mod forward; mod listener; mod run; mod store; -#[cfg(test)] -mod walkthrough; pub use ca::InterceptCa; pub use forward::{CredentialSource, GatewayTarget, IDENTITY_HEADER}; diff --git a/crates/agent/tests/README.md b/crates/agent/tests/README.md index 7fe26dd..f921184 100644 --- a/crates/agent/tests/README.md +++ b/crates/agent/tests/README.md @@ -11,3 +11,14 @@ cargo test -p agentdesktop-agent --test integration codex -- --nocapture Use `RUST_LOG=integration=debug` for detailed logs. Failed tests print the path to their saved artifacts. + +# Intercept listener test + +`intercept.rs` runs the Claude Desktop intercept listener end to end on +loopback through its public API, with a fake gateway and a fresh intercept CA. +It needs no Docker, keychain or admin rights, and runs on every platform. Each +step prints what it shows: + +```sh +cargo test -p agentdesktop-agent --test intercept -- --nocapture +``` diff --git a/crates/agent/src/intercept/walkthrough.rs b/crates/agent/tests/intercept.rs similarity index 90% rename from crates/agent/src/intercept/walkthrough.rs rename to crates/agent/tests/intercept.rs index e0aae97..7ee4f16 100644 --- a/crates/agent/src/intercept/walkthrough.rs +++ b/crates/agent/tests/intercept.rs @@ -1,8 +1,9 @@ -//! A walkthrough of the intercept listener, end to end on loopback. It needs -//! no admin rights, no keychain and no Docker. Read it as the demo: +//! A walkthrough of the intercept listener, end to end on loopback, through +//! its public API. It needs no admin rights, no keychain and no Docker, and +//! runs on every platform. Read it as the demo: //! //! ```text -//! cargo test -p agentdesktop-agent intercept::walkthrough -- --nocapture +//! cargo test -p agentdesktop-agent --test intercept -- --nocapture //! ``` //! //! The parts: @@ -15,9 +16,11 @@ //! - clients that trust only the CA they are given, the way Claude Desktop //! trusts the intercept CA once it is in the System keychain. //! -//! What it does not show: Claude Desktop itself, the macOS trust store, the -//! `egressProxyUrl` pin, or a real agentgateway. Those were covered by the -//! manual acceptance run. +//! The intercept CA's Name Constraints are checked against a verifier in the +//! unit tests of `intercept/ca.rs`, which can mint leaves the listener never +//! would. The daemon wiring, the `egressProxyUrl` pin and a real agentgateway +//! are covered by the Docker tests in `integration.rs`. Claude Desktop itself +//! and the macOS trust store are not covered here. use std::{ convert::Infallible, @@ -41,15 +44,13 @@ use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, KeyUsage use rustls::{ ClientConfig, RootCertStore, ServerConfig, pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}, - server::{ClientHello, ResolvesServerCert}, - sign::CertifiedKey, }; use tokio::{ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::{TcpListener, TcpStream}, }; -use super::{ +use agentdesktop_agent::intercept::{ CredentialSource, GatewayTarget, IDENTITY_HEADER, InterceptCa, InterceptConfig, Interceptor, serve, }; @@ -315,37 +316,6 @@ async fn start_other_origin() -> (SocketAddr, CertificateDer<'static>) { (address, ca_der) } -/// A TLS server that presents one fixed certificate: used to show what a -/// verifier does with leaves signed by the intercept CA. -async fn serve_fixed_certificate(key: Arc) -> SocketAddr { - #[derive(Debug)] - struct Fixed(Arc); - impl ResolvesServerCert for Fixed { - fn resolve(&self, _hello: ClientHello<'_>) -> Option> { - Some(self.0.clone()) - } - } - let config = ServerConfig::builder_with_provider(provider()) - .with_safe_default_protocol_versions() - .unwrap() - .with_no_client_auth() - .with_cert_resolver(Arc::new(Fixed(key))); - let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - tokio::spawn(async move { - while let Ok((stream, _)) = listener.accept().await { - let acceptor = acceptor.clone(); - tokio::spawn(async move { - if let Ok(mut tls) = acceptor.accept(stream).await { - let _ = tls.shutdown().await; - } - }); - } - }); - address -} - fn plain_gateway(address: SocketAddr) -> GatewayTarget { GatewayTarget::http(address.ip().to_string(), address.port()) } @@ -677,27 +647,7 @@ async fn walkthrough() { note(format!("plain-HTTP proxy request -> {not_connect}")); assert_eq!(not_connect, 405); - step("9. A verifier rejects names outside the intercept CA's Name Constraints"); - let verifier = tls_connector(&intercept_root, &[]); - for (name, key) in [ - ("claude.ai", ca.leaf("claude.ai").unwrap()), - ("example.com", ca.leaf_unchecked("example.com").unwrap()), - ("127.0.0.1", ca.leaf_unchecked("127.0.0.1").unwrap()), - ] { - let server = serve_fixed_certificate(key).await; - let outcome = tls_over(&verifier, name, TcpStream::connect(server).await.unwrap()).await; - note(format!( - "leaf for {name:<12} -> {}", - match &outcome { - Ok(_) => "accepted".to_owned(), - Err(error) => format!("rejected ({error})"), - } - )); - assert_eq!(outcome.is_ok(), name == "claude.ai"); - } - note("this is rustls-webpki; Electron's verifier and Apple's SecTrust are not tested here"); - - step("10. With the gateway down, requests fail (fail closed) and the health shows why"); + step("9. With the gateway down, requests fail (fail closed) and the health shows why"); let closed = TcpListener::bind("127.0.0.1:0") .await .unwrap() @@ -728,7 +678,7 @@ async fn walkthrough() { "nothing else answered in the gateway's place" ); - step("11. Pooled gateway connections are dropped after an idle gap (or a sleep)"); + step("10. Pooled gateway connections are dropped after an idle gap (or a sleep)"); let (idle_proxy, _) = start_listener( &ca, plain_gateway(gateway.address), @@ -773,7 +723,7 @@ async fn walkthrough() { "a gap longer than the idle reset starts a new connection" ); - step("12. Over TLS, the gateway connection uses HTTP/2 and keeps the original host"); + step("11. Over TLS, the gateway connection uses HTTP/2 and keeps the original host"); let (gateway_tls, gateway_root) = localhost_tls(&[b"h2", b"http/1.1"]); let tls_gateway = FakeGateway::start(Some(gateway_tls)).await; let mut roots = RootCertStore::empty(); @@ -830,7 +780,7 @@ async fn walkthrough() { "upgrades use HTTP/1.1" ); - step("13. The main listener's health"); + step("12. The main listener's health"); let health = interceptor.health().snapshot(); note(format!( "forwarded {}, gateway failures {}, tunnels {}, tunnel failures {}", From 4193367a3b30471a483127e474426abc96b1a42c Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Fri, 23 Oct 2026 11:41:14 -0400 Subject: [PATCH 9/9] test(agent): pull agentgateway from ghcr.io by digest, with retries The identity integration test failed in CI because a pull through cr.agentgateway.dev returned HTTP 525. The fixture now pulls the same v1.6.0 image from ghcr.io, pinned by digest, and tries the start up to three times, so a transient registry error does not fail the test. --- crates/agent/tests/common/agentgateway.rs | 40 ++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/crates/agent/tests/common/agentgateway.rs b/crates/agent/tests/common/agentgateway.rs index d672d59..8f53320 100644 --- a/crates/agent/tests/common/agentgateway.rs +++ b/crates/agent/tests/common/agentgateway.rs @@ -3,12 +3,17 @@ use std::time::{Duration, Instant}; use anyhow::Context; use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; use tokio::time::timeout; -use tracing::info; +use tracing::{info, warn}; -const IMAGE: &str = "cr.agentgateway.dev/agentgateway"; -const TAG: &str = "v1.6.0"; +// The image that `examples/` pulls as cr.agentgateway.dev/agentgateway:v1.6.0, +// from GitHub's registry and by digest: a CI pull through cr.agentgateway.dev +// failed with HTTP 525. +const IMAGE: &str = "ghcr.io/agentgateway/agentgateway"; +const TAG: &str = "v1.6.0@sha256:9d3e6044ddcdc0878b1787f77bd401252b95e22684203fb5e874c4c42d2ed90c"; const CONFIG_PATH: &str = "/etc/agentgateway/config.yaml"; const STARTUP_TIMEOUT: Duration = Duration::from_secs(60); +/// A registry error fails one attempt, not the test. +const START_ATTEMPTS: u64 = 3; /// A real agentgateway on the host network, listening on `127.0.0.1:port`. pub struct Agentgateway { @@ -20,14 +25,27 @@ impl Agentgateway { /// connections. pub async fn start(config: &str, port: u16) -> anyhow::Result { let started = Instant::now(); - let inner = GenericImage::new(IMAGE, TAG) - .with_cmd(["-f", CONFIG_PATH]) - .with_network("host") - .with_copy_to(CONFIG_PATH, config.as_bytes().to_vec()) - .with_startup_timeout(STARTUP_TIMEOUT) - .start() - .await - .context("start agentgateway (Docker is required)")?; + let mut attempt = 1; + let inner = loop { + let outcome = GenericImage::new(IMAGE, TAG) + .with_cmd(["-f", CONFIG_PATH]) + .with_network("host") + .with_copy_to(CONFIG_PATH, config.as_bytes().to_vec()) + .with_startup_timeout(STARTUP_TIMEOUT) + .start() + .await; + match outcome { + Ok(inner) => break inner, + Err(error) if attempt < START_ATTEMPTS => { + warn!(%error, attempt, "agentgateway did not start; trying again"); + tokio::time::sleep(Duration::from_secs(2 * attempt)).await; + attempt += 1; + } + Err(error) => { + return Err(error).context("start agentgateway (Docker is required)"); + } + } + }; let gateway = Self { inner }; let ready = timeout(STARTUP_TIMEOUT, async { while tokio::net::TcpStream::connect(("127.0.0.1", port))