Skip to content

feat(agent): send first-party Claude Desktop traffic through agentgateway - #194

Draft
ilackarms wants to merge 10 commits into
mainfrom
feat/claude-desktop-intercept
Draft

ilackarms wants to merge 10 commits into
mainfrom
feat/claude-desktop-intercept

Conversation

@ilackarms

@ilackarms ilackarms commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the design in #185 for #182. This is a draft, and it will change with the design.

Summary

In third-party inference mode, Claude Desktop does not sign in to claude.ai. Users lose their chat history, connectors and organization settings.

This PR adds programs.claudeDesktop.gatewayMode: intercept. Claude Desktop stays signed in to claude.ai, and its Anthropic traffic goes through agentgateway:

  1. The daemon pins Claude Desktop to a local listener with the managed setting egressProxyUrl. It writes no inference settings.
  2. The listener decrypts claude.ai and api.anthropic.com with a local CA. It tunnels all other hosts.
  3. The listener sends each decrypted request to agentgateway with its original host, Cookie and Authorization. It adds one header, x-agentdesktop-identity, with the gateway credential.
  4. agentgateway routes by host, applies its policies, and sends the request to Anthropic.

If the listener or the gateway is not available, requests fail. Nothing connects to an intercepted host directly.

Configuration

daemon:
  intercept:
    listen: 127.0.0.1:18471   # optional; the default
llmGateway:
  url: https://gateway.example.com
  interceptUrl: https://gateway.example.com   # optional; scheme, host and port only
programs:
  claudeDesktop:
    gatewayMode: intercept    # default: inference
  • Intercept mode needs the system daemon, an llmGateway, and useLlmGateway.
  • The gateway must use HTTPS. Plain HTTP is allowed only on a loopback address, because requests carry claude.ai cookies and tokens.
  • daemon.intercept.listen must be a loopback address with a fixed port.
  • Validation refuses auth, and pass-through keys that start with inference or egressProxy.
  • daemon --once refuses intercept mode, because no listener runs after it exits. Dry runs still work.

On the device

  • The daemon creates the intercept CA on first use. The key goes into the daemon's secret store (the System keychain on macOS). The certificate goes into <stateDir>/intercept-ca.pem.
  • The CA has X.509 Name Constraints. It can issue only for claude.ai and api.anthropic.com, and never for an IP address.
  • An administrator or an MDM profile must trust the CA as a root. Then Claude Desktop must restart, because it reads egressProxyUrl only at launch.
  • daemon-info has a new intercept section: listener state, CA path and SHA-256, request and failure counts, and the last failure.

On the gateway

Each intercepted host needs a passthrough route, matched by hostname, with TLS to the real host:

gateways:
  local:
    port: 4011
    bindAddress: 127.0.0.1
    protocol: HTTP
routes:
- name: claude-ai
  gateways: local
  hostnames: [claude.ai]
  policies:
    requestHeaderModifier:
      remove: [x-agentdesktop-identity]
  backends:
  - host: claude.ai:443
    policies:
      backendTLS:
        hostname: claude.ai
        alpn: ["http/1.1"]
# The same again for api.anthropic.com.

alpn: ["http/1.1"] lets WebSocket upgrades work on the upstream connection.

With gateway authentication, add jwtAuth to each route:

    jwtAuth:
      mode: strict
      location:
        header:
          name: x-agentdesktop-identity
          prefix: "Bearer "
      issuer: https://issuer.example.com
      audiences: [agentgateway]
      jwks:
        url: https://issuer.example.com/keys
  • mode: strict is necessary, because the default mode lets requests without a token through.
  • The daemon sends Bearer <token>, so prefix: "Bearer " is necessary. Without it, every request gets a 401.
  • jwtAuth removes the header after validation.

Listener behavior

  • It accepts only CONNECT, and only on a loopback address.
  • It serves HTTP/2 and HTTP/1.1, and it streams bodies, server-sent events and WebSockets.
  • It refuses a request whose host does not match the CONNECT host or the TLS server name. It also refuses an intercepted host on a port other than 443.
  • It replaces an identity header that the client sends. WebSocket tunnels stay open when the credential expires.
  • It resolves DNS for each connection, so a network change applies at once.
  • It drops pooled gateway connections after an idle gap on the wall clock, so it recovers after sleep.
  • If the listener cannot start, the daemon tries again every 10 s.

Testing

  • cargo test -p agentdesktop-agent --test intercept -- --nocapture runs the listener end to end on loopback, without Docker or admin rights. CI runs it on Linux and Windows. It covers HTTP/2, SSE, a 4 MiB upload, WebSockets, tunnels, the refusals, fail closed, and a TLS gateway.
  • A unit test in intercept/ca.rs shows that a verifier rejects leaves outside the Name Constraints.
  • Two Docker tests run the daemon in system mode, with curl in place of Claude Desktop. CI runs them on Linux.
    • intercept_lifecycle: the daemon writes only egressProxyUrl and serves the listener. With the gateway down, requests get a 502 and daemon-info shows the cause. With the mode off, the daemon removes the pin and stops the listener.
    • identity_through_agentgateway: agentgateway v1.6.0 runs with jwtAuth in strict mode, and a test OIDC provider signs the daemon in. Requests fail before sign-in and pass after it. The upstream gets the original host, Cookie and Authorization, but not the identity header. agentgateway refuses a missing or forged identity.
  • The macOS smoke workflow starts the real Claude Desktop, signed out, with the daemon and agentgateway. It checks the pin, TLS errors, claude.ai responses and daemon-info. It runs on a manual trigger and weekly, not on pull requests. It has not run yet. Two questions are open. Do Anthropic's terms allow Claude Desktop in CI? Does claude.ai send a bot challenge to runner addresses?
  • A manual run on macOS used the system daemon, a trusted CA and agentgateway v1.6.0. Claude Desktop stayed signed in, and Chat, the Code tab, Cowork and connectors worked, with about 450 requests. With the gateway or the daemon stopped, requests failed. They recovered without a Claude Desktop restart. When the mode was turned off, the daemon removed the pin.

Known limitations

  • Only macOS is tested with the real Claude Desktop.
  • The daemon does not restart Claude Desktop after the pin changes.
  • When the mode is turned off, the daemon removes the pin. It does not remove the CA trust.
  • At launch, Claude Desktop opens one connection to claude.ai before the pin applies. A no-bypass guarantee needs a network rule that lets only the gateway reach Anthropic.
  • Any local process can use the listener, and the listener adds the identity header to its requests. The gateway sees claude.ai cookies and tokens, so its logs must redact Cookie, Set-Cookie and Authorization.
  • In the manual run, 2 of about 450 requests got a TLS bad_record_mac alert between agentgateway and api.anthropic.com. Claude Desktop retried both. The cause is not known.
  • The desktop app does not show the intercept status yet.

Refs #182

Add crates/agent/src/intercept, a loopback HTTP CONNECT proxy for Claude
Desktop in first-party mode. It is not wired into the daemon yet.

- For the intercepted hosts, the listener terminates TLS with a leaf from
  an intercept CA and serves HTTP/2 and HTTP/1.1 by ALPN. It forwards
  each request to a gateway with the original host, the user's Cookie
  and Authorization unchanged, and one x-agentdesktop-identity header.
  It replaces any identity header that the client sends.
- Every other host is tunnelled without decryption.
- The intercept CA has Name Constraints for the intercepted hosts and
  excludes all IP ranges. Leaves are minted only for those hosts.
- The listener refuses a request whose host does not match the CONNECT
  host or the TLS server name (421 or a failed handshake), an
  intercepted host on a port other than 443, and requests that are not
  CONNECT.
- Bodies, server-sent events and WebSocket upgrades stream in both
  directions. Upgrades use HTTP/1.1 to the gateway. There is no 401
  retry, and tunnels do not close when a credential expires.
- Fail closed: when the gateway cannot be reached the request gets a
  502. Nothing connects to an intercepted host directly.
- Every connection resolves its host through the system resolver, so a
  DNS change after a network change applies at once. Pooled gateway
  connections are dropped after a wall-clock idle gap, which also
  covers sleep. A request without a body that fails before a response
  is sent once more.
- Health counts forwarded requests, failures and the last failure with
  its cause, for the daemon status.

The walkthrough test runs the listener end to end on loopback with no
admin rights:

  cargo test -p agentdesktop-agent intercept::walkthrough -- --nocapture
…arty mode

Wire the intercept listener into the system daemon and pin Claude Desktop
to it.

Configuration:
- programs.claudeDesktop.gatewayMode: inference (the default, today's
  third-party mode) or intercept.
- llmGateway.interceptUrl: where the listener forwards. Only the scheme,
  host and port are used. Without it, the listener uses those parts of
  llmGateway.url.
- daemon.intercept.listen: the loopback listener address, which defaults
  to 127.0.0.1:18471. It is rejected in user mode.
- Validation refuses intercept mode without a gateway, together with
  auth, or with pass-through keys that start with "inference" or
  "egressProxy", which would undo it. It also refuses an interceptUrl
  with a path.

Daemon:
- A supervisor in the system daemon follows the applied configuration.
  It starts the listener while Claude Desktop is in intercept mode,
  restarts it when the gateway changes, and stops it otherwise. When the
  listener cannot start, the supervisor tries again every 10 s.
- The intercept CA key is kept in the daemon's secret store and the
  certificate in <state_dir>/intercept-ca.pem. A new CA is made when the
  intercepted hosts change, and the daemon logs its path and SHA-256.
- The identity header carries the same gateway credential that the
  Claude Desktop credential helper gets today (client id
  claude-desktop). It is reused for at most 60 s and never within 30 s
  of its expiry.
- daemon-info has an intercept section: enabled, bound, the last error,
  the CA path and fingerprint, counts, and the last failure with its
  cause.

Provider:
- In intercept mode the Claude Desktop managed settings get
  egressProxyUrl pointing at the listener and no inference keys, and the
  credential helper is removed. The pin is written even when the
  listener is down, so Claude Desktop fails instead of connecting
  directly.

The schema is regenerated.
…ntegration suite

The new integration test runs the system daemon in a container with
programs.claudeDesktop.gatewayMode set to intercept. curl stands in for
Claude Desktop, because Claude Desktop has no Linux release. The test checks:

- the managed settings pin egressProxyUrl and set nothing else;
- daemon-info reports the listener and its CA;
- requests to claude.ai and api.anthropic.com reach the gateway with their
  host and cookie, and without the identity header that the client sent;
- other hosts are tunnelled;
- with interceptUrl on a closed port, requests fail with 502 and daemon-info
  shows the cause;
- the daemon reuses its stored CA after a restart;
- turning the mode off removes the pin and the listener.

The gateway fixture now also records the host, cookie and identity headers.
…gateway

The new integration test runs the daemon in Claude Desktop intercept mode
in front of agentgateway v1.6.0. jwtAuth in strict mode reads the token
from x-agentdesktop-identity. A fake OIDC provider signs the daemon in with
device authorization and issues ES256 tokens, and the test origin stands in
for Anthropic. The test checks:

- before sign-in, the listener returns 502, daemon-info shows that sign-in
  is required, and no request reaches the origin;
- after sign-in, requests to claude.ai and api.anthropic.com pass
  agentgateway, and the origin gets the original host, Cookie and
  Authorization without the identity header;
- agentgateway refuses a request with no identity or a forged one, and
  accepts one with a valid token.

agentgateway needs `prefix: "Bearer "` on the header location, because the
daemon sends `Bearer <token>`. Without it every request gets a 401.
Run the real Claude Desktop app in first-party intercept mode on a
GitHub-hosted macOS runner: build agentdesktop-headless, start
agentgateway v1.6.0 with passthrough routes for claude.ai and
api.anthropic.com, run the system daemon with
programs.claudeDesktop.gatewayMode: intercept, trust its CA in the
System keychain, then install Claude Desktop from its update feed
(signature and team checked) and launch it without signing in.

The job passes when main.log shows the pinned egress proxy and no
ERR_CERT or ERR_SSL lines, agentgateway logs claude.ai answering with
2xx or 3xx, and daemon-info reports intercept.forwarded above zero.
agentgateway logs Cloudflare's cf-mitigated response header, so a bot
challenge from claude.ai fails with its own message.

Triggered by workflow_dispatch and a weekly schedule only.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved credential-handling, transport-security, and proxy-configuration issues can expose credentials or interrupt Claude Desktop connectivity.

5 open issues
What changed in this PR

Adds first-party Claude Desktop routing through agentgateway for #182, preserving claude.ai sign-in and account features instead of switching to third-party inference mode.

Changes:

  • Adds intercept-mode configuration, validation, managed proxy settings, and daemon status.
  • Implements a local TLS-intercepting listener with a name-constrained CA, gateway identity forwarding, streaming, and tunnels.
  • Adds unit, integration, walkthrough, and macOS smoke coverage.
File Description
schema/​daemon-config.md Documents intercept configuration options.
schema/​daemon-config.json Adds generated intercept schemas.
crates/​core/​src/​model.rs Defines intercept status fields.
crates/​core/​src/​config.rs Adds intercept settings and validation.
crates/​agent/​tests/​integration.rs Registers Claude Desktop integration tests.
crates/​agent/​tests/​common/​mod.rs Exposes gateway and identity fixtures.
crates/​agent/​tests/​common/​issuer.rs Adds a test OIDC issuer.
crates/​agent/​tests/​common/​gateway.rs Records forwarded identity and session headers.
crates/​agent/​tests/​common/​agentgateway.rs Adds a real agentgateway container fixture.
crates/​agent/​src/​reconcile/​mod.rs Passes the intercept address into reconciliation.
crates/​agent/​src/​provider/​vscode/​tests.rs Updates fixtures for new fields.
crates/​agent/​src/​provider/​vscode/​settings_tests.rs Updates gateway configuration fixtures.
crates/​agent/​src/​provider/​mod.rs Adds intercept proxy reconciliation context.
crates/​agent/​src/​provider/​copilot/​tests.rs Updates fixtures for new fields.
crates/​agent/​src/​provider/​claude_desktop/​testdata/​Dockerfile Adds the daemon-and-curl test image.
crates/​agent/​src/​provider/​claude_desktop/​reconcile.rs Pins the proxy without inference settings.
crates/​agent/​src/​provider/​claude_desktop/​mod.rs Supplies the intercept address to reconciliation.
crates/​agent/​src/​provider/​claude_desktop/​integration_tests.rs Tests lifecycle and gateway authentication.
crates/​agent/​src/​provider/​claude_code/​mod.rs Updates reconciliation test context.
crates/​agent/​src/​llm_proxy.rs Updates existing proxy test fixtures.
crates/​agent/​src/​lib.rs Exposes the intercept module.
crates/​agent/​src/​intercept/​walkthrough.rs Exercises protocols and failure behavior on loopback.
crates/​agent/​src/​intercept/​store.rs Persists the CA key and certificate.
crates/​agent/​src/​intercept/​run.rs Supervises the listener and obtains credentials.
crates/​agent/​src/​intercept/​mod.rs Defines shared health and connection utilities.
crates/​agent/​src/​intercept/​listener.rs Handles CONNECT, TLS termination, and tunnels.
crates/​agent/​src/​intercept/​forward.rs Forwards requests, streams, and WebSockets.
crates/​agent/​src/​intercept/​ca.rs Generates constrained CA and leaf certificates.
crates/​agent/​src/​daemon.rs Integrates listener startup and reconciliation.
crates/​agent/​src/​api.rs Exposes live intercept status.
crates/​agent/​Cargo.toml Enables interception and test dependencies.
Cargo.toml Declares shared TLS and service dependencies.
Cargo.lock Records added agent dependencies.
.github/​workflows/​claude-desktop-smoke.yml Adds a real-app macOS smoke workflow.

Review effort: Balanced

Comment thread crates/agent/src/intercept/run.rs Outdated
Comment thread crates/agent/src/intercept/run.rs Outdated
Comment thread crates/agent/src/daemon.rs
Comment thread crates/agent/src/intercept/run.rs Outdated
Comment thread crates/core/src/config.rs Outdated
- Intercept mode requires an HTTPS gateway unless the gateway is on this
  device. The listener sends claude.ai cookies and tokens to it, so plain
  HTTP is allowed only for a loopback address, in validation and when the
  listener starts.
- The listener fetches credentials for the configuration it started with,
  not the last saved one, so the identity always belongs to the gateway
  that requests go to. Any llmGateway change, such as a new issuer,
  restarts the listener and drops its cached credential.
- A credential fetch runs on its own task, and only the wait for it times
  out. A token refresh that finishes late is still stored.
- daemon --once refuses intercept mode, because nothing would serve the
  pinned listener after it exits. Dry runs, and cleanup of a Claude Desktop
  the user opted out of, still work.
- daemon.intercept.listen refuses port 0, because Claude Desktop is pinned
  to that exact address.
Comment thread crates/agent/tests/intercept.rs
The walkthrough now lives in crates/agent/tests/intercept.rs, its own test
target, and uses only the public intercept API. It still needs no Docker,
keychain or admin rights.

- The Name Constraints check needs leaves that the listener never mints,
  so it moved to a unit test in intercept/ca.rs and checks the verifier
  directly.
- The Windows CI job runs the new target as well as the library tests, so
  the walkthrough keeps running on Windows.
- tests/README.md says how to run it.
… into feat/claude-desktop-intercept

# Conflicts:
#	crates/agent/Cargo.toml
#	crates/agent/src/provider/claude_desktop/reconcile.rs
The identity integration test failed in CI because a pull through
cr.agentgateway.dev returned HTTP 525. The fixture now pulls the same
v1.6.0 image from ghcr.io, pinned by digest, and tries the start up to
three times, so a transient registry error does not fail the test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants