diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324e925a..c032bb98 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/.github/workflows/claude-desktop-smoke.yml b/.github/workflows/claude-desktop-smoke.yml new file mode 100644 index 00000000..3bf3e5c4 --- /dev/null +++ b/.github/workflows/claude-desktop-smoke.yml @@ -0,0 +1,272 @@ +# 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 + # 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() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: claude-desktop-smoke + path: ${{ env.OUT }} + retention-days: 14 + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index 43284d78..5e8535d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,7 @@ dependencies = [ "rcgen", "reqwest", "rustls", + "rustls-native-certs", "serde", "serde_json", "serde_yaml", @@ -85,10 +86,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 36576999..7db3caaa 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" @@ -63,10 +64,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 8e323951..8b05e8c7 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" @@ -32,9 +36,10 @@ bytes.workspace = true clap.workspace = true glob = "0.3" hostname.workspace = true -hyper.workspace = true +hyper = { workspace = true, features = ["http2"] } hyper-rustls.workspace = true -hyper-util = { workspace = true, features = ["client-legacy", "http1"] } +rustls-native-certs.workspace = true +hyper-util = { workspace = true, features = ["client-legacy", "http1", "http2", "server-auto"] } http-body-util.workspace = true json5.workspace = true jsonc-parser.workspace = true @@ -51,15 +56,19 @@ 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 [dev-dependencies] -# The Windows secret envelope (secret_store.rs) is tested on every platform. +# The Windows secret envelope (secret_store.rs) is tested on every platform, +# and the fake identity provider in the integration tests signs its tokens. aws-lc-rs.workspace = true futures-util = "0.3" # Hand-rolled RFC 6455 handshake for the vscode-copilot-capi WebSocket diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 662ceb34..6281e90b 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 @@ -336,7 +338,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 { @@ -759,6 +763,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: crate::enrollment::EnrollmentState::new(false), @@ -769,6 +774,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; let cache = Arc::new(crate::llm_proxy::CredentialCache::default()); let expiry = SystemTime::now() diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index bbb8a35c..6617cd4b 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, @@ -154,6 +157,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 @@ -179,6 +189,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(|| { @@ -265,6 +281,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 @@ -461,7 +478,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!( @@ -627,6 +645,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, @@ -639,7 +660,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 @@ -847,6 +879,7 @@ fn describe_daemon( controller, llm_proxy, usage_receiver: None, + intercept: None, } } @@ -1117,6 +1150,19 @@ fn validate_one_shot( "--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() @@ -1712,6 +1758,7 @@ programs: logout: None, usage: None, programs: None, + intercept: None, }); for (method, expected_status) in [("GET", "200 OK"), ("POST", "405 Method Not Allowed")] { @@ -1851,6 +1898,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)); @@ -1937,6 +1985,7 @@ programs: logout: None, usage: None, programs: None, + intercept: None, }); for endpoint in ["/v1/health", "/v1/config", "/v1/daemon-info"] { @@ -2212,6 +2261,33 @@ programs: assert!(validate_one_shot(&config, true).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, false) + .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, false).expect("an opted-out Claude Desktop is only cleaned up"); + } + #[test] fn one_shot_rejects_the_vscode_program() { let vscode = parse_daemon( @@ -2286,6 +2362,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 new file mode 100644 index 00000000..9269db9c --- /dev/null +++ b/crates/agent/src/intercept/ca.rs @@ -0,0 +1,282 @@ +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, pem::PemObject}, + 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, + /// The CA private key, for the daemon's secret store. Never logged. + key_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(), + 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 + } + + 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: 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) + } + + 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 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()); + } + + /// 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/forward.rs b/crates/agent/src/intercept/forward.rs new file mode 100644 index 00000000..014a63e6 --- /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 00000000..30d1ac32 --- /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 00000000..1f4d8266 --- /dev/null +++ b/crates/agent/src/intercept/mod.rs @@ -0,0 +1,181 @@ +//! 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; +mod run; +mod store; + +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); + +/// 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/run.rs b/crates/agent/src/intercept/run.rs new file mode 100644 index 00000000..4d18cddb --- /dev/null +++ b/crates/agent/src/intercept/run.rs @@ -0,0 +1,451 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use agentdesktop_core::{ + config::{ClaudeDesktopGatewayMode, DaemonConfig, is_loopback_url}, + 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. 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); +/// 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 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: &Arc) -> 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, + settings: serde_json::to_value(gateway).unwrap_or_default(), + config: config.clone(), + }) +} + +/// 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(), + config: wanted.config.clone(), + cached: Arc::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. 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() + .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" 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(); + 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 configuration the listener started with. + config: Arc, + /// The last credential; the lock also makes concurrent misses share one fetch. + cached: Arc>>, +} + +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 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); + } + // 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(yaml: &str) -> Arc { + Arc::new(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 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(); + 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()); + assert!(gateway_target(&"http://localhost:4001/".parse().unwrap()).is_ok()); + assert!(gateway_target(&"http://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 00000000..24bfa0bb --- /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/lib.rs b/crates/agent/src/lib.rs index 1bf0f6de..eeb961e3 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; diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 5f310de6..424d24ee 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -1428,10 +1428,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(); @@ -2059,6 +2060,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(true), @@ -2068,6 +2070,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(); @@ -2449,6 +2452,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2458,6 +2462,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; (dir, state) } @@ -2628,6 +2633,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2637,6 +2643,7 @@ mod tests { logout: None, usage: None, programs: None, + intercept: None, }; (dir, state) } @@ -2698,6 +2705,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + intercept: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2707,6 +2715,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 0c016487..de85e3ba 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/integration_tests.rs b/crates/agent/src/provider/claude_desktop/integration_tests.rs new file mode 100644 index 00000000..6b252fdc --- /dev/null +++ b/crates/agent/src/provider/claude_desktop/integration_tests.rs @@ -0,0 +1,516 @@ +use std::time::Duration; + +use anyhow::{Context, ensure}; +use serde_json::{Value, json}; +use tokio::time::{sleep, timeout}; +use tracing::info; + +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", 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 +} + +/// 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 } }, + "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/mod.rs b/crates/agent/src/provider/claude_desktop/mod.rs index dde162eb..fddc7132 100644 --- a/crates/agent/src/provider/claude_desktop/mod.rs +++ b/crates/agent/src/provider/claude_desktop/mod.rs @@ -81,6 +81,7 @@ impl Provider for ClaudeDesktop { ctx.merge_user_settings, &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 58f49a89..9e1c4cd1 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; @@ -41,6 +43,7 @@ pub(super) fn plan( merge_existing: bool, credential_binary: &Path, socket: &Path, + intercept_proxy: Option, config: Option<(&ClaudeDesktopConfig, Option<&LlmGatewayConfig>)>, plan: &ReconcilePlan, ) -> anyhow::Result<()> { @@ -78,12 +81,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( @@ -98,7 +105,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" @@ -499,9 +506,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); }; @@ -695,13 +716,106 @@ 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 desktop = super::ClaudeDesktop { + managed_settings_path: settings_path.clone(), + credential_helper_path: helper.clone(), + }; + let apply = |yaml: &str| { + let config = parse_daemon(yaml).unwrap(); + let plan = crate::reconcile::ReconcilePlan::default(); + super::plan( + &desktop, + false, + 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() { @@ -719,8 +833,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!( @@ -772,6 +891,7 @@ programs: false, Path::new("agentdesktop"), Path::new("agentdesktop.sock"), + None, Some((config.programs.claude_desktop.as_ref().unwrap(), None)), plan, ) @@ -800,6 +920,7 @@ programs: false, Path::new("agentdesktop"), Path::new("agentdesktop.sock"), + None, Some((config.programs.claude_desktop.as_ref().unwrap(), None)), plan, ) @@ -830,8 +951,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 values = super::registry_values(&settings).unwrap(); assert_eq!(values["inferenceProvider"], "gateway"); @@ -909,8 +1035,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 error = super::registry_values(&settings).unwrap_err().to_string(); assert!(error.contains("differ only in case"), "{error}"); assert!(error.contains("inferenceprovider"), "{error}"); 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 00000000..b71bfd18 --- /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/src/provider/copilot/tests.rs b/crates/agent/src/provider/copilot/tests.rs index f4f47437..ae0a90bb 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_skips_a_configured_program_without_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 a9b88cda..56fa0a83 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -57,6 +57,11 @@ 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, } impl ReconcileContext { diff --git a/crates/agent/src/provider/pi/mod.rs b/crates/agent/src/provider/pi/mod.rs index ff031466..d2527f1a 100644 --- a/crates/agent/src/provider/pi/mod.rs +++ b/crates/agent/src/provider/pi/mod.rs @@ -97,6 +97,7 @@ mod tests { }; let ctx = ReconcileContext { llm_proxy: None, + intercept_proxy: None, merge_user_settings: false, credential_helper: root.path().join("agentdesktop"), socket: root.path().join("agentdesktop.sock"), @@ -122,6 +123,7 @@ mod tests { }; let mut ctx = ReconcileContext { llm_proxy: None, + intercept_proxy: None, merge_user_settings: true, credential_helper: root.path().join("agentdesktop"), socket: root.path().join("agentdesktop.sock"), @@ -143,6 +145,7 @@ mod tests { fn system_mode_needs_no_pi_paths() { let ctx = ReconcileContext { llm_proxy: None, + intercept_proxy: None, merge_user_settings: false, credential_helper: "agentdesktop".into(), socket: "agentdesktop.sock".into(), diff --git a/crates/agent/src/provider/vscode/settings_tests.rs b/crates/agent/src/provider/vscode/settings_tests.rs index a5df6b48..eaebf6bf 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 acd96b94..09bf3b2a 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_skips_a_configured_program_without_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 [ @@ -718,6 +720,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 e7be69b5..c1915616 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -77,6 +77,7 @@ impl Reconciler { credential_helper, socket, llm_proxy: None, + intercept_proxy: None, }, providers: Arc::new(vec![ Box::new(ClaudeCode { @@ -123,6 +124,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/agent/tests/README.md b/crates/agent/tests/README.md index 7fe26dd4..f921184a 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/tests/common/agentgateway.rs b/crates/agent/tests/common/agentgateway.rs new file mode 100644 index 00000000..8f533209 --- /dev/null +++ b/crates/agent/tests/common/agentgateway.rs @@ -0,0 +1,85 @@ +use std::time::{Duration, Instant}; + +use anyhow::Context; +use testcontainers::{ContainerAsync, GenericImage, ImageExt, runners::AsyncRunner}; +use tokio::time::timeout; +use tracing::{info, warn}; + +// 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 { + 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 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)) + .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/gateway.rs b/crates/agent/tests/common/gateway.rs index d4d15708..8b30c0f3 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/common/issuer.rs b/crates/agent/tests/common/issuer.rs new file mode 100644 index 00000000..9c9d3980 --- /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 95174647..e835b5ff 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(); diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 7896e6fa..404c9b50 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; diff --git a/crates/agent/tests/intercept.rs b/crates/agent/tests/intercept.rs new file mode 100644 index 00000000..7ee4f16b --- /dev/null +++ b/crates/agent/tests/intercept.rs @@ -0,0 +1,792 @@ +//! 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 --test intercept -- --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. +//! +//! 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, + 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}, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +use agentdesktop_agent::intercept::{ + 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) +} + +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. 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("10. 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("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(); + 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("12. 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/core/src/config.rs b/crates/core/src/config.rs index 83c7c126..26bb9699 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -101,6 +101,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, @@ -130,6 +134,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. @@ -287,6 +309,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", @@ -777,6 +815,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, @@ -785,6 +829,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`. @@ -1359,6 +1428,21 @@ fn parse_local_daemon(contents: &str) -> anyhow::Result { anyhow::bail!("daemon.usage.listen must be a loopback address"); } } + if let Some(listen) = config + .daemon + .as_ref() + .and_then(|daemon| daemon.intercept.listen) + { + 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(), config.sandbox.as_ref(), @@ -1435,6 +1519,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 => { @@ -1518,6 +1624,39 @@ fn validate_daemon( } } + if let Some(desktop) = &programs.claude_desktop + && desktop.gateway_mode == ClaudeDesktopGatewayMode::Intercept + { + 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!( + "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 [ ( @@ -1648,6 +1787,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() @@ -1667,8 +1816,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] @@ -2170,6 +2322,90 @@ 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() + ); + // 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] + 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", + ), + ( + "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}"); + } + } + #[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 20bd1734..b18d8918 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/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 6648e729..f72ef452 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -127,10 +127,13 @@ export type AgentKind = | "vscode" | "pi"; +export type ClaudeDesktopGatewayMode = "inference" | "intercept"; + export type AgentDraft = { kind: AgentKind; useGateway: boolean; settings: string; + gatewayMode?: ClaudeDesktopGatewayMode; }; export type SandboxConfigDocument = { diff --git a/frontend/controller/src/views/ConfigurationView.stories.tsx b/frontend/controller/src/views/ConfigurationView.stories.tsx index 91c05611..b307f4b8 100644 --- a/frontend/controller/src/views/ConfigurationView.stories.tsx +++ b/frontend/controller/src/views/ConfigurationView.stories.tsx @@ -139,6 +139,54 @@ export const BuildsYaml: Story = { }, }; +export const BuildsClaudeDesktopInterceptYaml: Story = { + play: async ({ canvas, canvasElement }) => { + await userEvent.click(canvas.getByText("Add agent")); + await userEvent.click( + canvas.getByRole("button", { name: /Claude Desktop/ }), + ); + await userEvent.selectOptions( + canvas.getByRole("combobox", { name: "Gateway mode" }), + "intercept", + ); + const output = canvasElement.querySelector(".output-card code"); + await expect(output?.textContent).toContain( + "claudeDesktop:\n gatewayMode: intercept", + ); + await expect( + canvas.getByText(/Requires the system daemon and a trusted intercept CA/), + ).toBeVisible(); + await userEvent.click( + canvas.getByRole("checkbox", { name: /Enable LLM gateway/ }), + ); + await expect( + canvas.getByRole("combobox", { name: "Gateway mode" }), + ).toHaveValue("inference"); + await expect(output).not.toHaveTextContent("gatewayMode: intercept"); + }, +}; + +export const LoadsClaudeDesktopInterceptYaml: Story = { + args: { + initialConfig: { + llmGateway: { url: "https://gateway.example.com" }, + programs: { claudeDesktop: { gatewayMode: "intercept" } }, + }, + }, + play: async ({ canvas, canvasElement }) => { + const output = canvasElement.querySelector(".output-card code"); + await expect( + canvas.getByRole("combobox", { name: "Gateway mode" }), + ).toHaveValue("intercept"); + await expect(output?.textContent).toContain( + "claudeDesktop:\n gatewayMode: intercept", + ); + await expect(output?.textContent).not.toContain( + "gatewayMode: intercept\n {}", + ); + }, +}; + export const BuildsSandboxYaml: Story = { play: async ({ canvas, canvasElement }) => { await userEvent.click(canvas.getByText("Sandbox")); diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index 448ddb07..b35363c4 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -9,7 +9,12 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import type { AgentDraft, AgentKind, DaemonConfigDocument } from "../types"; +import type { + AgentDraft, + AgentKind, + ClaudeDesktopGatewayMode, + DaemonConfigDocument, +} from "../types"; export interface ConfigurationViewProps { initialConfig?: DaemonConfigDocument | null; @@ -177,7 +182,19 @@ export function ConfigurationView({ setGateway(event.target.checked)} + onChange={(event) => { + const enabled = event.target.checked; + setGateway(enabled); + if (!enabled) { + setAgents((current) => + current.map((agent) => + agent.kind === "claudeDesktop" + ? { ...agent, gatewayMode: "inference" } + : agent, + ), + ); + } + }} /> {gateway && ( @@ -516,10 +533,56 @@ export function ConfigurationView({ onChange={(event) => updateAgent(agent.kind, { useGateway: event.target.checked, + ...(agent.kind === "claudeDesktop" && + !event.target.checked + ? { gatewayMode: "inference" } + : {}), }) } /> + {agent.kind === "claudeDesktop" && ( + <> +
+ + +
+ {agent.gatewayMode === "intercept" && ( +

+ + Requires the system daemon and a trusted intercept + CA on each device. +

+ )} + + )}