Repository navigation
Conversation
…JSON errors
Routes. Besides the hand-configured prefix-less route (client id from
daemon.llmProxy.clientId, credential mode from llmGateway.githubOAuth, upstream
proxyUrl or url; the route itself is unchanged, but like every route it now
requires the pairing value, which is a break for manual setups made before it
existed: README has the upgrade note and the client snippets), the proxy serves
three fixed prefixes for the managed Copilot programs: /copilot-cli and
/vscode-copilot send only the gateway identity with client ids copilot-cli and
vscode-copilot; /vscode-copilot-passthrough moves the client's own bearer token
to x-llm-token, adds the gateway identity and forwards to llmGateway.proxyUrl
(503 when that is unset). The prefix is stripped before forwarding. The client
id is decided by the prefix the reconciler writes into the client file, never
by request headers; with OIDC authentication the client id has no effect. A
first path segment that resembles a route name without matching one exactly
(case, suffix, doubled slash) is refused with 404 route_unknown rather than
served as the prefix-less route. Program gating of the prefixes comes with the
reconcilers.
Pairing. Every route requires a per-device pairing value in the
x-agentdesktop-pairing header, and only there, so Authorization keeps one
meaning. It is checked before any configuration is read, so an unpaired caller
learns nothing about the device and costs it no work. It is created once in the
daemon state directory (llm-proxy-pairing, 0600, 32 random bytes), kept across
restarts so client files stay valid, compared in constant time, and never
forwarded upstream even if a client sends it as a bearer. A missing or
unusable file (too short, not text) is regenerated with a warning; any other
read error is an error, not a reason to regenerate. It keeps other local
users on a shared host and browser pages off the proxy, provided the client
files that carry it are owner-only; it does not restrict the current user's own
processes. If it cannot be created the proxy stays off, reported through
daemon-info like a failed bind (attach_llm_proxy_pairing, tested).
Guards. Host must be a loopback address (any 127/8 or ::1, bracketed or bare,
localhost in any case, with or without port; a request without Host is
refused), Origin and CONNECT are refused as before, OPTIONS is refused, and
'..' segments are refused on a percent-decoded copy of the path (decoded to
bytes, so %2e%2e and encoded separators are caught and non-UTF-8 escapes are
allowed; an escape that is not two hex digits is refused) so a route cannot
escape its upstream base. The path checks are pure string checks and run
before the configuration is read.
Errors. Refused and failed requests return an OpenAI-style JSON body
{"error":{"message","type":"agentdesktop_error","code"}} so Copilot CLI and
VS Code display the message. A failure to obtain the gateway credential
(not enrolled, revoked, controller unreachable, OIDC refresh failed) is coded
agentdesktop_credential; a configuration read error returns a generic message
and the full error chain goes to the daemon log. The identity is fetched only
after a request has passed every other check.
Reconcilers get the bound address and the pairing through
ReconcileContext.llm_proxy (Reconciler::with_llm_proxy); the bind now happens
before the reconciler is built. The context documents that the pairing goes
only into owner-only files and that None means "remove the pointer".
Tests: route table (client id, mode, upstream per prefix), route matching,
host forms, pairing file creation and reuse, non-loopback Host and OPTIONS
refusals, prefixed route without / as bearer / wrong / correct pairing, prefix
stripping, '..' plain and percent-encoded and near-miss routes, pass-through
token move, pairing-as-token refusal, missing proxyUrl, default route paired,
pairing checked before configuration is read (unpaired 403 on a broken
remote-config.yaml, paired 500 without file detail), pairing failure switches
the proxy off with a reason, pairing file regenerated only when unusable, kept on a
permission error, and a state directory that cannot be created is an error;
path checks answered before the configuration is read (bad path on a broken
remote-config.yaml gets the path error); plus the three existing proxy tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner
Author
|
Superseded: landed upstream in agentdesktop-dev#134 (local LLM proxy). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on this fork so the diff is this PR only. James gets one integration PR from
copilot/integration(the tip of the stack) when the series is ready.Second listener change, stacked on #3 (and #2). Includes those commits until they merge.
Routes. Besides the prefix-less route (unchanged: client id from
daemon.llmProxy.clientId, credential mode fromllmGateway.githubOAuth, upstreamproxyUrlorurl), three fixed prefixes for the managed Copilot programs:/copilot-cliand/vscode-copilotsend only the gateway identity with client idscopilot-cliandvscode-copilot;/vscode-copilot-passthroughmoves the client's own bearer tox-llm-token, adds the gateway identity and forwards tollmGateway.proxyUrl(503 when unset). The prefix is stripped before forwarding. A first path segment that resembles a route name without matching one exactly (case, suffix, doubled slash) is refused with404 route_unknowninstead of being served as the prefix-less route. Program gating of the prefixes comes with the reconcilers.Pairing. Every route requires a per-device pairing value in
x-agentdesktop-pairing, only there, checked before any configuration is read. Created once in the state directory (llm-proxy-pairing, 0600), kept across restarts, constant-time compare, never forwarded upstream even if sent as a bearer. A missing or unusable file (too short, not text) is regenerated with a warning; any other read error is an error, so an existing pairing is never silently rotated. It keeps other local users on a shared host and browser pages off the proxy, provided the client files carrying it are owner-only; it does not restrict the current user's own processes. If it cannot be created the proxy stays off, reported through daemon-info like a failed bind.Guards. Loopback
Host(any 127/8 or ::1, bracketed or bare,localhostany case, with or without port; noHost→ refused),Origin/CONNECTas before,OPTIONSrefused,..segments refused on a percent-decoded copy of the path (bytes, so%2e%2eand encoded separators are caught; an escape that is not two hex digits is refused). The path checks run before the configuration is read.Errors. OpenAI-style JSON
{"error":{"message","type","code"}}so the clients display the message. Credential failures are codedagentdesktop_credential; a configuration read error gets a generic message with the error chain in the daemon log. Identity is fetched only after every other check.Reconcilers get the bound address and pairing via
ReconcileContext.llm_proxy(Reconciler::with_llm_proxy); the bind happens before the reconciler is built. The context states the owner-only file requirement and thatNonemeans "remove the pointer".Review process. Frozen acceptance spec, then three review lenses on two models (conformance, adversarial, maintainer fit), refutation of the majors by a third pass, and delta re-reviews until two passes without majors. Findings folded in over three versions: default route was unpaired; pairing checked after the config read (leaked file detail);
..only caught unencoded; near-miss route names served as the default route; README VS Code snippet had an invented shape (now the lab-verifiedchatLanguageModels.jsonarray); non-hex escapes decoded; non-UTF-8 pairing file left the proxy off until deleted.Checks: agent 110 + core 18 tests (proxy + daemon proxy tests 35), clippy
-D warnings, fmt, schema unchanged. README section rewritten (route table, pairing, upgrade note for manual setups, error codes,allowedClientIdsfor the new ids).Lab, Linux VM, user mode, Copilot CLI 1.0.88, independent run against the stacked build: default route without pairing → 403
pairing_invalid, with the header → 200 (Enterprise gateway loghttp.path=/v1/chat/completions http.status=200 llm.client="copilot-cli"); prefixed route with the pairing as bearer → 403, with the header → 200;/vscode-copilot→ gateway logllm.client="vscode-copilot" user="demo@ai-demo.example";Host: 127.0.0.2:18095→ 200,Host: attacker.example→ 403host_not_allowed;/copilot-cli/../admin→ 400path_invalid;/Copilot-cli/v1/...→ 404route_unknown; Copilot CLI 1.0.88 withproviders.jsonon/copilot-cli/v1plus the pairing header answers, gateway logllm.client="copilot-cli". Note for the prefix-less route: the path is appended to the base URL as sent, so clients there send/v1/...(a bare/chat/completionsreached the provider and got its 404).🤖 Generated with Claude Code