Skip to content

feat(agent): local LLM proxy for clients without an API-key helper - #134

Merged
peterj merged 3 commits into
agentdesktop-dev:mainfrom
LutzLange:upstream/s2-loopback-proxy
Oct 6, 2026
Merged

peterj merged 3 commits into
agentdesktop-dev:mainfrom
LutzLange:upstream/s2-loopback-proxy

Conversation

@LutzLange

@LutzLange LutzLange commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of #86 (series overview: #86 overview). Based on main (#133 is merged). The second commit addresses the review.

Why

Copilot CLI and VS Code Copilot Chat accept only a static API key; they have no credential-helper hook like Claude Code's apiKeyHelper. So the device's gateway identity cannot reach the gateway from these clients.

Change

An optional loopback proxy, daemon.llmProxy.listen, forwards a client's model requests to the gateway and adds the device's gateway credential (the same one the API-key helpers return).

  • Routes. One fixed prefix per client (/copilot-cli, /vscode-copilot, /vscode-copilot-passthrough). The client ID the gateway sees comes from the route, not the request.
  • Startup. Bound before the first apply. A failed bind keeps the daemon running and shows in daemon-info (llmProxy.bound, error).
  • Forwarding. Bodies are buffered (32 MiB cap) so a 401 is retried once with a fresh credential. Controller credentials are cached for up to 60 s, with one controller fetch at a time per client ID.
  • Config. llmGateway.proxyUrl sets a proxy target other than url. llmGateway.githubOAuth sets the upstream GitHub credential: taken from the client's request or from a GitHub App device flow.

John Howard and James McShane started the listener and the GitHub credential handling; both are co-authors.

Risk and compatibility

  • The proxy hands out the user's gateway credential, so:
    • it runs in user mode only;
    • it listens on loopback;
    • every request needs a per-device pairing value (random, stored 0600 in the state directory) in x-agentdesktop-pairing.
  • A Host check and a refused OPTIONS keep browser pages out.
  • A client's own credential never goes upstream as its identity. The gateway routes drop it. The pass-through route moves the client's token to x-llm-token and puts the gateway identity in Authorization.
  • New controller-deliverable keys: upgrade the controller first. Older daemons reject a configuration that uses them.

How to review

  • llm_proxy.rs holds routing, pairing, checks and forwarding.
  • daemon.rs holds the bind and startup.
  • github_oauth.rs holds the GitHub credential sources.
  • About half of the added lines are tests (the tests module in llm_proxy.rs).

Tests

CI green. Unit tests cover routing, pairing, the Host check, errors, the body cap, the retry and the credential cache. Lab-tested on a Linux VM with a controller-managed daemon: the bind and a failed bind, daemon-info, and a request through each route to agentgateway. Review fixes (second commit), lab-tested on the same VM: 8 concurrent requests on one route share one controller issuance, a request sent while a long one is still running reuses its credential, two routes fetch independently, and an ftp:// proxyUrl is rejected by the controller. The GitHub sign-in failure page and the Bearer scheme are covered by unit tests only; they were not re-tested on the live path.

Screenshot

Desktop app, daemon information with the local proxy (here not bound, with the reason). Storybook: Desktop/Status, StandaloneProxyUnbound.

Desktop app, daemon information with the local proxy (here not bound, with the reason). Storybook: Desktop/Status, StandaloneProxyUnbound.

🤖 Generated with Claude Code

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.

Copilot review overview

🟡 Changes recommended

URL validation, authorization parsing, OAuth failure handling, credential-fetch concurrency, and the standalone example contain unresolved issues.

Review effort: Balanced
Findings: 1 High severity · 5 Medium severity

Open (6)
What changed in this PR

Adds the foundational user-mode loopback LLM proxy for clients without credential-helper support, including authenticated forwarding, GitHub OAuth, diagnostics, and configuration.

Changes:

  • Adds paired, route-aware proxy forwarding with credential caching and retry handling.
  • Integrates GitHub device-flow credentials and proxy startup/status reporting.
  • Adds schemas, desktop status UI, documentation, examples, and tests.
File Description
schema/​daemon-config.md Documents proxy configuration.
schema/​daemon-config.json Adds generated proxy/OAuth schema.
README.md Documents setup, routing, and security.
frontend/​desktop/​src/​views/​StatusView.stories.tsx Tests proxy status display.
frontend/​desktop/​src/​types.ts Types proxy daemon information.
frontend/​desktop/​src/​stories/​fixtures.ts Adds proxy status fixtures.
frontend/​desktop/​src/​components/​DaemonInformation.tsx Displays proxy state.
examples/​standalone/​config.yaml Adds optional proxy/OAuth configuration.
examples/​standalone/​agentgateway.yaml Changes standalone gateway routing.
crates/​core/​src/​model.rs Models proxy runtime status.
crates/​core/​src/​config.rs Adds and validates proxy configuration.
crates/​agent/​src/​remote.rs Bounds controller credential calls.
crates/​agent/​src/​reconcile/​mod.rs Supplies proxy context to reconcilers.
crates/​agent/​src/​provider/​mod.rs Extends reconciliation context.
crates/​agent/​src/​provider/​claude_code/​mod.rs Updates test context construction.
crates/​agent/​src/​oidc.rs Supports continued GitHub authorization.
crates/​agent/​src/​llm_proxy.rs Implements proxy routing and forwarding.
crates/​agent/​src/​lib.rs Registers new modules.
crates/​agent/​src/​identity.rs Loads device IDs for cache tagging.
crates/​agent/​src/​github_oauth.rs Implements GitHub device OAuth.
crates/​agent/​src/​gateway_oidc.rs Chains gateway and GitHub sign-in.
crates/​agent/​src/​daemon.rs Binds and runs the proxy.
crates/​agent/​src/​api.rs Shares gateway credential acquisition.
crates/​agent/​Cargo.toml Adds proxy dependencies.
Cargo.toml Configures TLS dependencies.
Cargo.lock Locks dependency updates.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread examples/standalone/agentgateway.yaml Outdated
audiences: [agentdesktop-local]
jwks:
url: http://dex:5557/dex/keys
url: http://localhost:5557/dex/keys

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: examples/standalone/ is back to main's version, so the JWKS URL is http://dex:5557/dex/keys again.

Comment on lines +143 to +147
let code: DeviceCode = client
.post(format!("{base}/login/device/code"))
.header("Accept", "application/json")
.form(&[("client_id", client_id)])
.send()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: if a continued sign-in fails before the GitHub page is served (device-code request, URL check, refresh), the page is now served in its failed state on the callback port, so the tab shows the retry message. Test: continued_flow_shows_failure_when_github_setup_fails; not re-tested on the live path.

Comment thread crates/agent/src/llm_proxy.rs Outdated
Comment on lines +538 to +544
.map(|value| {
value
.strip_prefix("Bearer ")
.unwrap_or(value)
.trim()
.to_owned()
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: the scheme is matched case-insensitively. Test: bearer_scheme_is_case_insensitive.

Comment on lines +851 to +853
let cached = cache_key
.as_ref()
.and_then(|(key, device_id)| cache.get(key, device_id));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: fetches are single-flight per cache key (different keys stay independent), and a fetched credential is cached as soon as it arrives and dropped if the gateway rejects it. Test: concurrent_misses_share_one_fetch_per_key (7 concurrent misses on 2 keys, 2 fetches). Lab: 8 concurrent requests on one route, one controller issuance.

Comment thread crates/core/src/config.rs Outdated
Comment on lines +840 to +844
if let Some(proxy_url) = &gateway.proxy_url
&& (proxy_url.query().is_some() || proxy_url.fragment().is_some())
{
anyhow::bail!("LLM gateway proxyUrl cannot include a query or fragment");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: proxyUrl gets the same scheme, host, credentials, query and fragment checks as url. Test: rejects_an_invalid_llm_gateway_proxy_url. Lab: the controller rejects an ftp:// proxyUrl and keeps the last good configuration.

Comment thread examples/standalone/agentgateway.yaml Outdated
Comment on lines +38 to +40
# Claude desktop requires an explicit model
- name: "claude-haiku-4-5"
provider: anthropic
- name: "gpt-6-luna"
provider: copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f058fb: the Copilot change to the standalone example was left over from an earlier branch and is reverted. The standalone example is the Claude walkthrough again; the Copilot example is examples/copilot/ in #138.

LutzLange and others added 2 commits October 2, 2026 23:54
Part of agentdesktop-dev#86. Adds an optional loopback proxy (`daemon.llmProxy.listen`, user
mode only) that forwards a client's model requests to the LLM gateway with
the device's gateway credential, for clients that cannot run an API-key
helper (GitHub Copilot CLI, VS Code Copilot Chat).

- Bound before the first apply; a failed bind keeps the daemon running and
  is reported in daemon-info (`llmProxy.bound`, `error`), also shown in the
  desktop app's daemon information panel.
- Fixed route prefix per client (`/copilot-cli`, `/vscode-copilot`,
  `/vscode-copilot-passthrough`), so the client ID the gateway sees comes
  from the route, not from the request.
- A per-device pairing value (`x-agentdesktop-pairing`) is required on every
  route; loopback Host check; OpenAI-style JSON errors.
- Request bodies are buffered (size cap) so a 401 is retried once with a
  fresh credential; controller credentials are cached briefly.
- `llmGateway.proxyUrl` for a proxy target that differs from `url`;
  `llmGateway.githubOAuth` for the upstream GitHub credential, taken from
  the client's own request (`source: request`) or from a GitHub App device
  flow (`deviceFlow`).

Co-authored-by: John Howard <john.howard@solo.io>
Co-authored-by: James McShane <james.mcshane@solo.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- examples/standalone: restore the Claude example from main. The Copilot
  example comes with the guide later in the series; the JWKS URL stays
  on the Compose service name.
- GitHub OAuth: when a continued sign-in fails before the GitHub page is
  served, serve it in its failed state, so the browser tab stops polling.
- Proxy: parse the Bearer scheme case-insensitively.
- Proxy: credential fetches are single-flight per cache key, and a fetched
  credential is cached as soon as it arrives (dropped if the gateway
  rejects it), so concurrent requests share one controller call.
- Config: llmGateway.proxyUrl gets the scheme, host and credentials checks
  of llmGateway.url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crates/agent/src/github_oauth.rs Outdated
"Connect GitHub",
&format!(
r#"
<p id="description">Connect your GitHub account to use Copilot through Agentdesktop.</p>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could you move this html into github_oauth.html or and then load it instead of having it directly in source like this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0e777cd: the page body is now crates/agent/src/github_oauth.html, loaded with include_str!; the escaped user code fills a {{user_code}} placeholder. The two smaller fragments this PR adds in oidc.rs follow that file's existing inline pages; happy to move those (or all of oidc.rs's pages) into files too if you'd prefer. The stack above (#135–#138) is rebased onto it.

The page body moves out of a format! string into github_oauth.html,
loaded with include_str!; the escaped user code replaces {{user_code}}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
if continuation.is_none()
&& let Err(error) = open::that_detached(&page_url)
{
tracing::warn!(%error, "could not open the Agentdesktop sign-in page; use the URL above");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: agentdesktop

@peterj
peterj merged commit 65f70c1 into agentdesktop-dev:main Oct 6, 2026
3 checks passed
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