Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ notify = "8.2"
notify-debouncer-full = "0.7"
jsonwebtoken = { version = "11.0", features = ["aws_lc_rs"] }
json5 = "1.3"
jsonc-parser = { version = "0.34", default-features = false, features = ["cst", "serde_json"] }
keyring = "4.2"
keyring-core = "1"
libc = "0.2"
Expand All @@ -59,7 +60,7 @@ serde_yaml = "0.9"
sha2 = "0.11"
similar = "3.2"
sqlx = { version = "0.9", default-features = false, features = ["any", "macros", "migrate", "postgres", "runtime-tokio", "sqlite", "tls-rustls-aws-lc-rs"] }
tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = "0.1"
tonic = { version = "0.14", features = ["transport", "tls-aws-lc"] }
tonic-prost = "0.14"
Expand Down
324 changes: 319 additions & 5 deletions README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions crates/agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ hyper-rustls.workspace = true
hyper-util = { workspace = true, features = ["client-legacy", "http1"] }
http-body-util.workspace = true
json5.workspace = true
jsonc-parser.workspace = true
memchr.workspace = true
open.workspace = true
rand.workspace = true
Expand All @@ -55,6 +56,10 @@ url.workspace = true

[dev-dependencies]
futures-util = "0.3"
# Hand-rolled RFC 6455 handshake for the vscode-copilot-capi WebSocket
# tunnel tests (llm_proxy.rs): Sec-WebSocket-Accept is
# base64(sha1(key + the RFC 6455 GUID)).
sha1 = "0.11"
tempfile = "3"
testcontainers = "0.28"
tracing-subscriber.workspace = true
Expand Down
148 changes: 148 additions & 0 deletions crates/agent/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ struct ResolvedDaemonArgs {
codex: ResolvedToolConfigPath,
open_code: ResolvedOpenCodeStartupConfig,
grok: ResolvedToolConfigPath,
/// The Copilot CLI providers file; `None` in system mode (user-only program).
copilot_providers: Option<PathBuf>,
/// VS Code's `chatLanguageModels.json`; `None` in system mode (user-only program).
vscode_chat_models: Option<PathBuf>,
/// VS Code's user `settings.json`; `None` in system mode (user-only program).
vscode_settings: Option<PathBuf>,
once: bool,
dry_run: bool,
}
Expand Down Expand Up @@ -131,6 +137,20 @@ impl DaemonArgs {
if llm_proxy_listen.is_some() && !config::valid_client_id(&llm_proxy_client_id) {
bail!("invalid daemon.llmProxy.clientId");
}
// VS Code's chatLanguageModels.json and settings.json live in the
// user's own profile, like the Copilot CLI providers file: a system
// daemon has no user file to manage, so an explicit override is
// rejected up front.
if !user
&& let Some(field) = [
("config", startup.vscode.config.is_some()),
("settings", startup.vscode.settings.is_some()),
]
.into_iter()
.find_map(|(field, set)| set.then_some(field))
{
bail!("daemon.vscode.{field} requires --user (or daemon.user: true)");
}
if !user {
return Ok(ResolvedDaemonArgs {
user: false,
Expand Down Expand Up @@ -180,6 +200,14 @@ impl DaemonArgs {
.config
.unwrap_or_else(reconcile::default_grok_managed_config_path),
},
copilot_providers: {
if startup.copilot.config.is_some() {
bail!("daemon.copilot.config requires --user (or daemon.user: true)");
}
None
},
vscode_chat_models: None,
vscode_settings: None,
once: self.once || self.dry_run,
dry_run: self.dry_run,
});
Expand Down Expand Up @@ -250,6 +278,22 @@ impl DaemonArgs {
.join("managed_config.toml")
}),
},
copilot_providers: Some(match startup.copilot.config {
Some(path) => path,
None => reconcile::default_copilot_providers_path()?,
}),
vscode_chat_models: Some(
startup
.vscode
.config
.unwrap_or_else(|| reconcile::default_vscode_chat_models_path(&home)),
),
vscode_settings: Some(
startup
.vscode
.settings
.unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)),
),
once: self.once || self.dry_run,
dry_run: self.dry_run,
})
Expand Down Expand Up @@ -371,6 +415,9 @@ where
args.open_code.config.clone(),
args.open_code.plugin.clone(),
args.grok.config.clone(),
args.copilot_providers.clone(),
args.vscode_chat_models.clone(),
args.vscode_settings.clone(),
agentdesktop_client_executable()?,
socket.clone(),
)
Expand Down Expand Up @@ -856,6 +903,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow
if !config.telemetry.events.is_empty() {
bail!("--once cannot collect telemetry because hooks require the daemon to remain running");
}
if config.programs.copilot.is_some() {
bail!(
"--once cannot manage the GitHub Copilot CLI providers file because its local proxy requires the daemon to remain running"
);
}
if config.programs.vscode.is_some() {
bail!(
"--once cannot manage the VS Code chatLanguageModels.json and settings.json files because their local proxy requires the daemon to remain running"
);
}
let authenticated_gateway_is_used = config
.llm_gateway
.as_ref()
Expand Down Expand Up @@ -886,6 +943,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow
.grok
.as_ref()
.is_some_and(|program| program.use_llm_gateway),
config
.programs
.copilot
.as_ref()
.is_some_and(|program| program.use_llm_gateway),
config
.programs
.vscode
.as_ref()
.is_some_and(|program| program.use_llm_gateway),
]
.into_iter()
.any(|used| used);
Expand Down Expand Up @@ -1607,6 +1674,48 @@ telemetry:
);
}

#[test]
fn one_shot_rejects_the_copilot_program() {
let copilot = parse_daemon(
r#"
llmGateway:
url: https://gateway.example.com
programs:
copilot:
models:
gpt-4.1: {}
"#,
)
.unwrap();
assert!(
validate_one_shot(&copilot)
.unwrap_err()
.to_string()
.contains("Copilot")
);
}

#[test]
fn one_shot_rejects_the_vscode_program() {
let vscode = parse_daemon(
r#"
llmGateway:
url: https://gateway.example.com
programs:
vscode:
models:
gpt-4.1-mini: {}
"#,
)
.unwrap();
assert!(
validate_one_shot(&vscode)
.unwrap_err()
.to_string()
.contains("VS Code")
);
}

#[test]
fn dry_run_rejects_controller_managed_configuration() {
let managed = parse_daemon(
Expand Down Expand Up @@ -1660,6 +1769,45 @@ programs:
startup
}

#[test]
fn system_mode_rejects_a_copilot_providers_path() {
let mut startup = config::DaemonStartupConfig::default();
startup.copilot.config = Some(PathBuf::from("/tmp/providers.json"));
let error = resolve_error(daemon_args(false), startup);
assert!(
error
.to_string()
.contains("daemon.copilot.config requires --user"),
"{error:#}"
);
}

#[test]
fn system_mode_rejects_a_vscode_settings_path() {
let mut startup = config::DaemonStartupConfig::default();
startup.vscode.settings = Some(PathBuf::from("/tmp/settings.json"));
let error = resolve_error(daemon_args(false), startup);
assert!(
error
.to_string()
.contains("daemon.vscode.settings requires --user"),
"{error:#}"
);
}

#[test]
fn system_mode_rejects_a_vscode_chat_models_path() {
let mut startup = config::DaemonStartupConfig::default();
startup.vscode.config = Some(PathBuf::from("/tmp/chatLanguageModels.json"));
let error = resolve_error(daemon_args(false), startup);
assert!(
error
.to_string()
.contains("daemon.vscode.config requires --user"),
"{error:#}"
);
}

#[test]
fn system_mode_rejects_a_configured_llm_proxy() {
let error = resolve_error(
Expand Down
Loading