Skip to content

Commit d0f81e7

Browse files
LutzLangeclaude
andcommitted
feat: llmGateway.whenProxyUnavailable and a GitHub Copilot example
Proxy-unavailable policy - While the daemon runs without its local LLM proxy, the programs behind it used to remove their managed entries; for VS Code on GitHub's models that removes the CAPI override, so Copilot Chat talks to GitHub directly, past the gateway. - `llmGateway.whenProxyUnavailable` now decides for `programs.copilot` and `programs.vscode`: `failClosed` (default) leaves the managed files as they are, so the tools stay pointed at the loopback port and fail; `failOpen` removes them until the proxy is back. The program is `inactive` with a reason that names the policy. - Program removal, a mode switch, `useLlmGateway: false` and a missing `proxyUrl` remove as before. The controller's configuration view offers the choice ("Fail open without the local proxy") when a Copilot program is selected. Older daemons and controllers reject the key (`deny_unknown_fields`); the default needs no key. Example - `examples/copilot/`: Dex, agentgateway, a daemon config and a step-by-step guide for the Copilot CLI, VS Code on own models and VS Code on GitHub's models, with what to check at each step (drift, conflicts, removal, the proxy gone), plus the settings the GitHub organisation owner configures (`github-side.md`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2812a0b commit d0f81e7

22 files changed

Lines changed: 794 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ The incoming path and query are appended to the gateway base URL: with
133133
`llmGateway.proxyUrl: https://gateway.example/prefix` (or `url`, when `proxyUrl`
134134
is unset), `/v1/messages` goes to `https://gateway.example/prefix/v1/messages`.
135135
Avoid repeating `/v1` in both URLs.
136+
137+
**Without the proxy.** When the daemon runs without its loopback proxy
138+
(`daemon.llmProxy.listen` unset, or the address could not be bound),
139+
`llmGateway.whenProxyUnavailable` decides what happens to the files of the
140+
programs that use the proxy (`programs.copilot`, `programs.vscode`). With
141+
`failClosed`, the default, the daemon leaves their managed entries as they
142+
are: the Copilot CLI and VS Code stay pointed at the loopback port, and those
143+
requests fail instead of reaching GitHub past the gateway. If the address could
144+
not be bound because another process holds it, that process receives those
145+
requests, with VS Code's Copilot tokens and the pairing value: use the default
146+
on devices with a single user, as for the proxy in general. With
147+
`failOpen`, the daemon removes the entries until the proxy is back: VS Code on
148+
GitHub's models then talks to GitHub directly, and the Copilot CLI and VS Code
149+
on own models lose the gateway's models. Either way the programs report
150+
`inactive` with the reason, and the next apply with the proxy writes the
151+
current entries. A stopped daemon changes no file, so the tools fail until it
152+
runs again, whatever the policy. The policy covers only the files the daemon
153+
writes: whether developers can reach GitHub's models without the gateway is
154+
set by the organisation's Copilot policy and egress rules. Set the key only
155+
after the controller and the daemons run a version that knows it; older ones
156+
reject a configuration that carries it.
157+
158+
```yaml
159+
llmGateway:
160+
url: https://gateway.example.com
161+
whenProxyUnavailable: failOpen # default failClosed
162+
```
136163
The proxy does not discover models or rewrite model IDs; configure those in the
137164
client. VS Code's built-in **Custom Endpoint** provider can use this endpoint
138165
without the experimental extension in `vscode/`. The provider reads
@@ -269,6 +296,14 @@ device authorization again. Non-expiring App user tokens are also supported.
269296
Request bodies remain untouched. The existing Claude credential-helper flow is
270297
unchanged; the local proxy always uses the gateway identity in `Authorization`.
271298

299+
### GitHub Copilot: a runnable example
300+
301+
[`examples/copilot`](examples/copilot/README.md) runs Dex and Agentgateway
302+
locally and walks through the Copilot CLI, VS Code on the gateway's models and
303+
VS Code on GitHub's models, with what to check at each step (drift,
304+
conflicts, removal) and the settings the GitHub organisation owner controls
305+
([`examples/copilot/github-side.md`](examples/copilot/github-side.md)).
306+
272307
### Managed program: GitHub Copilot CLI
273308

274309
`programs.copilot` makes the daemon write the Copilot CLI's BYOK provider
@@ -312,8 +347,9 @@ daemon's sidecar next to the file is gone, entries under the managed names
312347
that carry a pairing header are replaced on the next apply, and those that
313348
carry this daemon's own pairing value are removed when the program goes
314349
away; entries with another pairing value are left alone. The file is written owner-only. Removing
315-
`programs.copilot`, setting `useLlmGateway: false`, or running without the
316-
proxy takes the managed entries out again and restores the user's file with
350+
`programs.copilot` or setting `useLlmGateway: false` takes the managed
351+
entries out again (running without the proxy depends on
352+
`llmGateway.whenProxyUnavailable`, see **Without the proxy**) and restores the user's file with
317353
its previous mode (deleted only if the daemon created it and nothing else is
318354
left).
319355

@@ -482,11 +518,14 @@ single-quoted string; the top level is not an object;
482518
leaves the file untouched and blocks every managed file on the device until it
483519
is resolved (and stops the daemon at startup); fix the file in VS Code and
484520
re-apply (with `daemon.reconcileInterval` set, the next tick applies it). Removal (program
485-
absent, `ownModels`, `useLlmGateway: false`, no gateway, no `proxyUrl`, or no
486-
proxy, with a warning) takes the key and the two entries out and deletes the
487-
file only if the daemon created it. Restart VS Code after an apply that changes
521+
absent, `ownModels`, `useLlmGateway: false`, no gateway, or no `proxyUrl`)
522+
takes the key and the two entries out and deletes the file only if the daemon
523+
created it. Without the proxy, `llmGateway.whenProxyUnavailable` decides (see
524+
**Without the proxy** above): the default keeps the override, so Copilot Chat
525+
fails instead of reaching GitHub directly. Restart VS Code after an apply that changes
488526
the file (first apply, re-pairing after a re-enrollment). There is no
489-
`--dry-run` preview: a dry run has no proxy and plans a removal; `--once`
527+
`--dry-run` preview: a dry run has no proxy, so it lists nothing for this file
528+
under `failClosed` and the removal under `failOpen`; `--once`
490529
refuses the program.
491530

492531
Caveats. `github.copilot.advanced.debug.overrideCapiUrl` is an undocumented
@@ -614,7 +653,7 @@ device-wide state. The device page in the controller lists them under
614653
| `unchanged` | The program is configured and nothing needed to change. |
615654
| `removed` | The program is no longer configured and its managed content was removed. |
616655
| `conflict` | A managed file holds configuration the daemon will not overwrite; the detail names the file. |
617-
| `inactive` | The program uses the LLM gateway but the local LLM proxy is not running (see `llmProxy.error` in daemon-info), so its files point nowhere and were removed. |
656+
| `inactive` | The program uses the LLM gateway but the local LLM proxy is not running (see `llmProxy.error` in daemon-info): its entries were left pointing at the loopback port (`whenProxyUnavailable: failClosed`, the default) or removed (`failOpen`). |
618657
| `blocked` | The program had changes, but none were written because another program conflicted or failed; the detail names that program. |
619658
| `failed` | Planning or writing this program failed; the detail carries the error. |
620659

‎crates/agent/src/provider/copilot/reconcile.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,21 @@ pub(super) fn plan(
103103
) -> anyhow::Result<()> {
104104
let state_path = json_merge::state_path(path);
105105
let pairing = proxy.map(|(_, pairing)| pairing);
106-
let Some((config, Some(_gateway))) = configured else {
106+
let Some((config, Some(gateway))) = configured else {
107107
remove(path, &state_path, pairing, plan)?;
108108
return Ok(());
109109
};
110110
let Some((listen, pairing)) = proxy else {
111+
if crate::reconcile::fail_closed_without_proxy(gateway, plan) {
112+
tracing::debug!(
113+
path = %path.display(),
114+
"programs.copilot is configured but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so the managed Copilot CLI providers are left as they are"
115+
);
116+
return Ok(());
117+
}
111118
tracing::debug!(
112119
path = %path.display(),
113-
"programs.copilot is configured but the local LLM proxy is not available, so the Copilot CLI is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed Copilot CLI providers"
120+
"programs.copilot is configured but the local LLM proxy is not available, so the Copilot CLI is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); whenProxyUnavailable: failOpen, removing the managed Copilot CLI providers"
114121
);
115122
plan.inactive(crate::reconcile::PROXY_ABSENT_REASON);
116123
remove(path, &state_path, None, plan)?;

‎crates/agent/src/provider/copilot/tests.rs‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ use std::{
77
net::SocketAddr,
88
};
99

10-
use agentdesktop_core::config::{CopilotConfig, CopilotModel, CopilotProvider, LlmGatewayConfig};
10+
use agentdesktop_core::config::{
11+
CopilotConfig, CopilotModel, CopilotProvider, LlmGatewayConfig, ProxyUnavailable,
12+
};
1113
use serde_json::{Value, json};
1214

1315
use super::discovery;
@@ -49,6 +51,7 @@ fn sample_gateway() -> LlmGatewayConfig {
4951
authentication: None,
5052
proxy_url: None,
5153
github_oauth: None,
54+
when_proxy_unavailable: Default::default(),
5255
}
5356
}
5457

@@ -575,11 +578,12 @@ fn removal_when_use_llm_gateway_is_effectively_false() {
575578
}
576579

577580
#[test]
578-
fn removal_when_the_loopback_proxy_is_unavailable() {
581+
fn fail_open_removes_the_entries_when_the_loopback_proxy_is_unavailable() {
579582
let dir = tempfile::tempdir().unwrap();
580583
let path = dir.path().join("providers.json");
581584
let config = sample_config();
582-
let gateway = sample_gateway();
585+
let mut gateway = sample_gateway();
586+
gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen;
583587

584588
let changes = ReconcilePlan::default();
585589
plan(
@@ -603,6 +607,50 @@ fn removal_when_the_loopback_proxy_is_unavailable() {
603607
);
604608
}
605609

610+
#[test]
611+
fn fail_closed_keeps_the_entries_when_the_loopback_proxy_is_unavailable() {
612+
let dir = tempfile::tempdir().unwrap();
613+
let path = dir.path().join("providers.json");
614+
let config = sample_config();
615+
let gateway = sample_gateway();
616+
assert_eq!(
617+
gateway.when_proxy_unavailable,
618+
ProxyUnavailable::FailClosed,
619+
"the default"
620+
);
621+
let changes = ReconcilePlan::default();
622+
plan(
623+
&path,
624+
Some((listen_addr(), PAIRING)),
625+
Some((&config, Some(&gateway))),
626+
&changes,
627+
)
628+
.unwrap();
629+
changes.apply().unwrap();
630+
let state = super::super::json_merge::state_path(&path);
631+
let (before, state_before) = (fs::read(&path).unwrap(), fs::read(&state).unwrap());
632+
633+
let changes = ReconcilePlan::default();
634+
plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap();
635+
assert_eq!(
636+
changes.inactive_reason().as_deref(),
637+
Some(crate::reconcile::FAIL_CLOSED_REASON)
638+
);
639+
changes.apply().unwrap();
640+
assert_eq!(
641+
fs::read(&path).unwrap(),
642+
before,
643+
"the providers stay, pointed at the loopback port"
644+
);
645+
assert_eq!(fs::read(&state).unwrap(), state_before);
646+
647+
// Removing the program still removes, whatever the policy.
648+
let changes = ReconcilePlan::default();
649+
plan(&path, None, None, &changes).unwrap();
650+
changes.apply().unwrap();
651+
assert!(!path.exists());
652+
}
653+
606654
// --- plan(): conflicts ------------------------------------------------------
607655

608656
#[test]

‎crates/agent/src/provider/vscode/reconcile.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,21 @@ pub(super) fn plan(
9898
) -> anyhow::Result<()> {
9999
let state_path = json_merge::state_path(path);
100100
let pairing = proxy.map(|(_, pairing)| pairing);
101-
let Some((config, Some(_gateway))) = configured else {
101+
let Some((config, Some(gateway))) = configured else {
102102
remove(path, &state_path, pairing, plan)?;
103103
return Ok(());
104104
};
105105
let Some((listen, pairing)) = proxy else {
106+
if crate::reconcile::fail_closed_without_proxy(gateway, plan) {
107+
tracing::debug!(
108+
path = %path.display(),
109+
"programs.vscode is configured but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so the managed chat language models are left as they are"
110+
);
111+
return Ok(());
112+
}
106113
tracing::debug!(
107114
path = %path.display(),
108-
"programs.vscode is configured but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed chat language models"
115+
"programs.vscode is configured but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); whenProxyUnavailable: failOpen, removing the managed chat language models"
109116
);
110117
plan.inactive(crate::reconcile::PROXY_ABSENT_REASON);
111118
remove(path, &state_path, None, plan)?;

‎crates/agent/src/provider/vscode/settings.rs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,11 @@ pub(super) fn read_state(bytes: &[u8]) -> Option<SettingsState> {
489489
///
490490
/// Edits only when the program is set with `copilotChat: githubModels`,
491491
/// uses the gateway, the gateway has `proxyUrl` and the loopback proxy is
492-
/// available; removes the managed keys otherwise (with a warning when the
493-
/// proxy is the missing part), the same decision shape as `reconcile::plan`.
492+
/// available; removes the managed keys otherwise, the same decision shape as
493+
/// `reconcile::plan`. The one exception is the proxy being the missing part:
494+
/// then `llmGateway.whenProxyUnavailable` decides, and `failClosed` (the default) leaves
495+
/// the file as it is, so VS Code stays pointed at the loopback port
496+
/// instead of reaching GitHub past the gateway.
494497
pub(super) fn plan(
495498
path: &Path,
496499
proxy: Option<(SocketAddr, &str)>,
@@ -507,9 +510,18 @@ pub(super) fn plan(
507510
return remove(path, &state_path, proxy, plan);
508511
}
509512
let Some((listen, pairing)) = proxy else {
513+
if let Some((_, Some(gateway))) = configured
514+
&& crate::reconcile::fail_closed_without_proxy(gateway, plan)
515+
{
516+
debug!(
517+
path = %path.display(),
518+
"programs.vscode uses copilotChat: githubModels but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so the settings are left as they are"
519+
);
520+
return Ok(());
521+
}
510522
tracing::debug!(
511523
path = %path.display(),
512-
"programs.vscode uses copilotChat: githubModels but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed settings"
524+
"programs.vscode uses copilotChat: githubModels but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); whenProxyUnavailable: failOpen, removing the managed settings"
513525
);
514526
plan.inactive(crate::reconcile::PROXY_ABSENT_REASON);
515527
return remove(path, &state_path, None, plan);

‎crates/agent/src/provider/vscode/settings_tests.rs‎

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
88
use std::{collections::BTreeMap, fs, net::SocketAddr, path::PathBuf};
99

10-
use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeCopilotChat, VsCodeModel};
10+
use agentdesktop_core::config::{
11+
LlmGatewayConfig, ProxyUnavailable, VsCodeConfig, VsCodeCopilotChat, VsCodeModel,
12+
};
1113
use jsonc_parser::{ParseOptions, cst::CstRootNode};
1214
use serde_json::{Value, json};
1315

1416
use super::settings::{
1517
Removal, SETTINGS_STATE_VERSION, SettingsConflict, SettingsState, edit_settings, plan,
1618
read_state, remove_settings, settings_path,
1719
};
20+
use crate::provider::json_merge;
1821
use crate::reconcile::ReconcilePlan;
1922

2023
/// The two keys the daemon owns in `settings.json`.
@@ -57,6 +60,7 @@ fn gateway_with_proxy() -> LlmGatewayConfig {
5760
authentication: None,
5861
proxy_url: Some("https://gateway.example.com/copilot-proxy".parse().unwrap()),
5962
github_oauth: None,
63+
when_proxy_unavailable: Default::default(),
6064
}
6165
}
6266

@@ -365,12 +369,13 @@ fn removal_no_proxy_url_keeps_user_keys() {
365369
}
366370

367371
#[test]
368-
fn removal_no_proxy_available_keeps_user_keys_with_a_warning() {
372+
fn fail_open_removal_no_proxy_available_keeps_user_keys() {
369373
let dir = tempfile::tempdir().unwrap();
370374
let path = dir.path().join("settings.json");
371375
write_user_settings(&path, &json!({"editor.fontSize": 14}));
372376
let config = github_models_config();
373-
let gateway = gateway_with_proxy();
377+
let mut gateway = gateway_with_proxy();
378+
gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen;
374379
apply_managed(&path, listen_addr(), PAIRING, &config, &gateway);
375380

376381
// `ctx.llm_proxy` is `None` (bind failed, proxy off) even though the
@@ -384,6 +389,82 @@ fn removal_no_proxy_available_keeps_user_keys_with_a_warning() {
384389
assert!(after.get(OVERRIDE_KEY).is_none(), "{after}");
385390
}
386391

392+
#[test]
393+
fn fail_closed_leaves_the_file_and_sidecar_until_the_proxy_is_back() {
394+
let dir = tempfile::tempdir().unwrap();
395+
let path = dir.path().join("settings.json");
396+
write_user_settings(&path, &json!({"editor.fontSize": 14}));
397+
let config = github_models_config();
398+
let gateway = gateway_with_proxy();
399+
apply_managed(&path, listen_addr(), PAIRING, &config, &gateway);
400+
let state = json_merge::state_path(&path);
401+
let (before, state_before) = (fs::read(&path).unwrap(), fs::read(&state).unwrap());
402+
403+
let changes = apply_removal(&path, None, Some((&config, Some(&gateway))));
404+
assert_eq!(
405+
changes.inactive_reason().as_deref(),
406+
Some(crate::reconcile::FAIL_CLOSED_REASON)
407+
);
408+
changes.apply().unwrap();
409+
assert_eq!(
410+
fs::read(&path).unwrap(),
411+
before,
412+
"the override stays, pointed at the loopback port"
413+
);
414+
assert_eq!(fs::read(&state).unwrap(), state_before);
415+
416+
// The proxy back, on another port: the current URL is written.
417+
let other: SocketAddr = "127.0.0.1:18199".parse().unwrap();
418+
apply_managed(&path, other, PAIRING, &config, &gateway);
419+
assert_eq!(read(&path)[OVERRIDE_KEY], override_url(other, PAIRING));
420+
421+
// Removing the program still removes, whatever the policy.
422+
apply_removal(&path, None, None).apply().unwrap();
423+
let after = read(&path);
424+
assert!(after.get(OVERRIDE_KEY).is_none(), "{after}");
425+
assert_eq!(after["editor.fontSize"], 14);
426+
}
427+
428+
#[test]
429+
fn fail_closed_does_not_create_a_file_without_the_proxy() {
430+
let dir = tempfile::tempdir().unwrap();
431+
let path = dir.path().join("settings.json");
432+
let changes = apply_removal(
433+
&path,
434+
None,
435+
Some((&github_models_config(), Some(&gateway_with_proxy()))),
436+
);
437+
changes.apply().unwrap();
438+
assert!(!path.exists());
439+
}
440+
441+
#[test]
442+
fn when_proxy_unavailable_defaults_to_fail_closed_and_parses_fail_open() {
443+
let parse = |extra: Value| {
444+
let mut document = json!({"url": "https://gateway.example.com"});
445+
document
446+
.as_object_mut()
447+
.unwrap()
448+
.extend(extra.as_object().unwrap().clone());
449+
serde_json::from_value::<LlmGatewayConfig>(document)
450+
};
451+
let default = parse(json!({})).unwrap();
452+
assert_eq!(default.when_proxy_unavailable, ProxyUnavailable::FailClosed);
453+
assert!(
454+
serde_json::to_value(&default)
455+
.unwrap()
456+
.get("whenProxyUnavailable")
457+
.is_none()
458+
);
459+
assert_eq!(
460+
parse(json!({"whenProxyUnavailable": "failOpen"}))
461+
.unwrap()
462+
.when_proxy_unavailable,
463+
ProxyUnavailable::FailOpen
464+
);
465+
assert!(parse(json!({"whenProxyUnavailable": "failSoft"})).is_err());
466+
}
467+
387468
#[test]
388469
fn removal_of_a_created_and_emptied_file_deletes_it() {
389470
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)