Skip to content

Commit bc14ee3

Browse files
LutzLangeclaude
andauthored
feat(agent): manage Copilot CLI and VS Code Copilot Chat (#135)
* feat(agent): manage Copilot CLI and VS Code Copilot Chat Part of #86. Two programs that point Copilot at the local LLM proxy, user mode only: - `programs.copilot` writes the Copilot CLI's BYOK provider registry (`~/.copilot/providers.json`, or `$COPILOT_HOME` / `COPILOT_PROVIDERS_CONFIG`): providers `agentdesktop` (OpenAI-shaped) and `agentdesktop-anthropic` on the proxy's `/copilot-cli` route, with the pairing header; the user's own providers are kept. Discovery reports the CLI and its version from the npm manifest. - `programs.vscode` with `copilotChat: ownModels` (default) writes `chatLanguageModels.json` for Copilot Chat's built-in Custom Endpoint provider on `/vscode-copilot`; the user's entries are kept. - `copilotChat: githubModels` keeps GitHub's own models and the user's Copilot seat: the daemon sets `github.copilot.advanced.debug.overrideCapiUrl` in the user `settings.json` to the proxy's `/vscode-copilot-capi/<pairing>` route (pairing in the path, since VS Code cannot add a header), forwards to `llmGateway.proxyUrl` with the client's Copilot token moved aside, tunnels the Responses WebSocket for agent mode, forwards `_ping` without a credential, and keeps the override out of Settings Sync. `settings.json` is edited in place (JSONC): only the managed keys change, comments, key order and formatting stay, and removal restores the file byte for byte. Managed files are written 0600 and carry a sidecar that records what the daemon added, so removal takes out exactly that. A file the daemon cannot parse is a conflict and left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): keep a missing models key out of providers.json; README precision - Copilot CLI: removing our providers by their pairing header (no sidecar) no longer adds "models": null to a providers.json that has no models key. - README: the CAPI WebSocket tunnel closes after 4 to 5 minutes with the controller's default gatewayJwt.lifetime, also mid-turn; VS Code's reconnect behaviour is not verified. - README: the settings.json removal exceptions are listed in full (multi-line empty object, comment-only file). - README: the providers.json sidecar holds the user's own apiKey values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): an unreadable VS Code settings sidecar fails the apply; sidecar mode repaired Review on #135. - `.settings.json.agentdesktop` that is not JSON or not a known settings state now fails the apply, as the providers.json and chatLanguageModels.json sidecars already do: like a conflict, every managed file on the device stays untouched, also while `programs.vscode` is not configured (removal reads the sidecar too), and the daemon does not start. Before, the sidecar was ignored and rebuilt from the sidecar-less rules, so a user's own `overrideCapiUrl` was lost on removal. The error names the sidecar path and, for a syntax error, the line and column only, never a value. A sidecar without `version` (the earlier whole-document form) is still upgraded; a leading byte-order mark is accepted. - An identical sidecar whose mode is looser than 0600 is written again at 0600, as settings.json is; settings.json and its sidecar use the same check (a 0700 settings.json is now also tightened). The rewrite of the sidecar alone is not shown in the plan summary, since settings.json is unchanged. - README: the unreadable-sidecar behaviour and the sidecar's mode. Behaviour change: the test `an_unreadable_sidecar_warns_and_apply_proceeds` is replaced. Tests: 9 new, 2 of them unix-only. The file-mode tests in this module (old and new) assume umask 022. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bd296af commit bc14ee3

36 files changed

Lines changed: 8707 additions & 93 deletions

‎Cargo.lock‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ notify = "8.2"
4040
notify-debouncer-full = "0.7"
4141
jsonwebtoken = { version = "11.0", features = ["aws_lc_rs"] }
4242
json5 = "1.3"
43+
jsonc-parser = { version = "0.34", default-features = false, features = ["cst", "serde_json"] }
4344
keyring = "4.2"
4445
keyring-core = "1"
4546
libc = "0.2"
@@ -59,7 +60,7 @@ serde_yaml = "0.9"
5960
sha2 = "0.11"
6061
similar = "3.2"
6162
sqlx = { version = "0.9", default-features = false, features = ["any", "macros", "migrate", "postgres", "runtime-tokio", "sqlite", "tls-rustls-aws-lc-rs"] }
62-
tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
63+
tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
6364
tokio-stream = "0.1"
6465
tonic = { version = "0.14", features = ["transport", "tls-aws-lc"] }
6566
tonic-prost = "0.14"

‎README.md‎

Lines changed: 319 additions & 5 deletions
Large diffs are not rendered by default.

‎crates/agent/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ hyper-rustls.workspace = true
3535
hyper-util = { workspace = true, features = ["client-legacy", "http1"] }
3636
http-body-util.workspace = true
3737
json5.workspace = true
38+
jsonc-parser.workspace = true
3839
memchr.workspace = true
3940
open.workspace = true
4041
rand.workspace = true
@@ -55,6 +56,10 @@ url.workspace = true
5556

5657
[dev-dependencies]
5758
futures-util = "0.3"
59+
# Hand-rolled RFC 6455 handshake for the vscode-copilot-capi WebSocket
60+
# tunnel tests (llm_proxy.rs): Sec-WebSocket-Accept is
61+
# base64(sha1(key + the RFC 6455 GUID)).
62+
sha1 = "0.11"
5863
tempfile = "3"
5964
testcontainers = "0.28"
6065
tracing-subscriber.workspace = true

‎crates/agent/src/daemon.rs‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ struct ResolvedDaemonArgs {
8181
codex: ResolvedToolConfigPath,
8282
open_code: ResolvedOpenCodeStartupConfig,
8383
grok: ResolvedToolConfigPath,
84+
/// The Copilot CLI providers file; `None` in system mode (user-only program).
85+
copilot_providers: Option<PathBuf>,
86+
/// VS Code's `chatLanguageModels.json`; `None` in system mode (user-only program).
87+
vscode_chat_models: Option<PathBuf>,
88+
/// VS Code's user `settings.json`; `None` in system mode (user-only program).
89+
vscode_settings: Option<PathBuf>,
8490
once: bool,
8591
dry_run: bool,
8692
}
@@ -131,6 +137,20 @@ impl DaemonArgs {
131137
if llm_proxy_listen.is_some() && !config::valid_client_id(&llm_proxy_client_id) {
132138
bail!("invalid daemon.llmProxy.clientId");
133139
}
140+
// VS Code's chatLanguageModels.json and settings.json live in the
141+
// user's own profile, like the Copilot CLI providers file: a system
142+
// daemon has no user file to manage, so an explicit override is
143+
// rejected up front.
144+
if !user
145+
&& let Some(field) = [
146+
("config", startup.vscode.config.is_some()),
147+
("settings", startup.vscode.settings.is_some()),
148+
]
149+
.into_iter()
150+
.find_map(|(field, set)| set.then_some(field))
151+
{
152+
bail!("daemon.vscode.{field} requires --user (or daemon.user: true)");
153+
}
134154
if !user {
135155
return Ok(ResolvedDaemonArgs {
136156
user: false,
@@ -180,6 +200,14 @@ impl DaemonArgs {
180200
.config
181201
.unwrap_or_else(reconcile::default_grok_managed_config_path),
182202
},
203+
copilot_providers: {
204+
if startup.copilot.config.is_some() {
205+
bail!("daemon.copilot.config requires --user (or daemon.user: true)");
206+
}
207+
None
208+
},
209+
vscode_chat_models: None,
210+
vscode_settings: None,
183211
once: self.once || self.dry_run,
184212
dry_run: self.dry_run,
185213
});
@@ -250,6 +278,22 @@ impl DaemonArgs {
250278
.join("managed_config.toml")
251279
}),
252280
},
281+
copilot_providers: Some(match startup.copilot.config {
282+
Some(path) => path,
283+
None => reconcile::default_copilot_providers_path()?,
284+
}),
285+
vscode_chat_models: Some(
286+
startup
287+
.vscode
288+
.config
289+
.unwrap_or_else(|| reconcile::default_vscode_chat_models_path(&home)),
290+
),
291+
vscode_settings: Some(
292+
startup
293+
.vscode
294+
.settings
295+
.unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)),
296+
),
253297
once: self.once || self.dry_run,
254298
dry_run: self.dry_run,
255299
})
@@ -371,6 +415,9 @@ where
371415
args.open_code.config.clone(),
372416
args.open_code.plugin.clone(),
373417
args.grok.config.clone(),
418+
args.copilot_providers.clone(),
419+
args.vscode_chat_models.clone(),
420+
args.vscode_settings.clone(),
374421
agentdesktop_client_executable()?,
375422
socket.clone(),
376423
)
@@ -856,6 +903,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow
856903
if !config.telemetry.events.is_empty() {
857904
bail!("--once cannot collect telemetry because hooks require the daemon to remain running");
858905
}
906+
if config.programs.copilot.is_some() {
907+
bail!(
908+
"--once cannot manage the GitHub Copilot CLI providers file because its local proxy requires the daemon to remain running"
909+
);
910+
}
911+
if config.programs.vscode.is_some() {
912+
bail!(
913+
"--once cannot manage the VS Code chatLanguageModels.json and settings.json files because their local proxy requires the daemon to remain running"
914+
);
915+
}
859916
let authenticated_gateway_is_used = config
860917
.llm_gateway
861918
.as_ref()
@@ -886,6 +943,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow
886943
.grok
887944
.as_ref()
888945
.is_some_and(|program| program.use_llm_gateway),
946+
config
947+
.programs
948+
.copilot
949+
.as_ref()
950+
.is_some_and(|program| program.use_llm_gateway),
951+
config
952+
.programs
953+
.vscode
954+
.as_ref()
955+
.is_some_and(|program| program.use_llm_gateway),
889956
]
890957
.into_iter()
891958
.any(|used| used);
@@ -1607,6 +1674,48 @@ telemetry:
16071674
);
16081675
}
16091676

1677+
#[test]
1678+
fn one_shot_rejects_the_copilot_program() {
1679+
let copilot = parse_daemon(
1680+
r#"
1681+
llmGateway:
1682+
url: https://gateway.example.com
1683+
programs:
1684+
copilot:
1685+
models:
1686+
gpt-4.1: {}
1687+
"#,
1688+
)
1689+
.unwrap();
1690+
assert!(
1691+
validate_one_shot(&copilot)
1692+
.unwrap_err()
1693+
.to_string()
1694+
.contains("Copilot")
1695+
);
1696+
}
1697+
1698+
#[test]
1699+
fn one_shot_rejects_the_vscode_program() {
1700+
let vscode = parse_daemon(
1701+
r#"
1702+
llmGateway:
1703+
url: https://gateway.example.com
1704+
programs:
1705+
vscode:
1706+
models:
1707+
gpt-4.1-mini: {}
1708+
"#,
1709+
)
1710+
.unwrap();
1711+
assert!(
1712+
validate_one_shot(&vscode)
1713+
.unwrap_err()
1714+
.to_string()
1715+
.contains("VS Code")
1716+
);
1717+
}
1718+
16101719
#[test]
16111720
fn dry_run_rejects_controller_managed_configuration() {
16121721
let managed = parse_daemon(
@@ -1660,6 +1769,45 @@ programs:
16601769
startup
16611770
}
16621771

1772+
#[test]
1773+
fn system_mode_rejects_a_copilot_providers_path() {
1774+
let mut startup = config::DaemonStartupConfig::default();
1775+
startup.copilot.config = Some(PathBuf::from("/tmp/providers.json"));
1776+
let error = resolve_error(daemon_args(false), startup);
1777+
assert!(
1778+
error
1779+
.to_string()
1780+
.contains("daemon.copilot.config requires --user"),
1781+
"{error:#}"
1782+
);
1783+
}
1784+
1785+
#[test]
1786+
fn system_mode_rejects_a_vscode_settings_path() {
1787+
let mut startup = config::DaemonStartupConfig::default();
1788+
startup.vscode.settings = Some(PathBuf::from("/tmp/settings.json"));
1789+
let error = resolve_error(daemon_args(false), startup);
1790+
assert!(
1791+
error
1792+
.to_string()
1793+
.contains("daemon.vscode.settings requires --user"),
1794+
"{error:#}"
1795+
);
1796+
}
1797+
1798+
#[test]
1799+
fn system_mode_rejects_a_vscode_chat_models_path() {
1800+
let mut startup = config::DaemonStartupConfig::default();
1801+
startup.vscode.config = Some(PathBuf::from("/tmp/chatLanguageModels.json"));
1802+
let error = resolve_error(daemon_args(false), startup);
1803+
assert!(
1804+
error
1805+
.to_string()
1806+
.contains("daemon.vscode.config requires --user"),
1807+
"{error:#}"
1808+
);
1809+
}
1810+
16631811
#[test]
16641812
fn system_mode_rejects_a_configured_llm_proxy() {
16651813
let error = resolve_error(

0 commit comments

Comments
 (0)