Skip to content
Closed
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
396 changes: 391 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
219 changes: 210 additions & 9 deletions crates/agent/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ 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>,
/// `daemon.reconcileInterval`; `None` means no periodic re-apply.
reconcile_interval: Option<Duration>,
once: bool,
dry_run: bool,
}
Expand Down Expand Up @@ -131,6 +139,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 +202,15 @@ 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,
reconcile_interval: startup.reconcile_interval,
once: self.once || self.dry_run,
dry_run: self.dry_run,
});
Expand Down Expand Up @@ -250,6 +281,23 @@ 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)),
),
reconcile_interval: startup.reconcile_interval,
once: self.once || self.dry_run,
dry_run: self.dry_run,
})
Expand Down Expand Up @@ -371,6 +419,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 All @@ -383,9 +434,9 @@ where
.context("preview daemon configuration")?;
} else {
validate_one_shot(&config)?;
reconciler
.apply(&config)
.context("apply daemon configuration")?;
let (report, applied) = reconciler.apply_with_report(&config);
report.log();
applied.context("apply daemon configuration")?;
println!("Reconciliation complete.");
}
return Ok(());
Expand Down Expand Up @@ -432,21 +483,43 @@ where
} else {
Some(local_config)
};
if let Some(initial_config) = initial_config {
reconciler
.apply(&initial_config)
.context("apply initial daemon configuration")?;
if let Some(initial_config) = &initial_config {
// The outcomes are logged before a failure stops the daemon.
let (report, applied) = reconciler.apply_with_report(initial_config);
report.log();
applied.context("apply initial daemon configuration")?;
// With a controller, the cached controller configuration is what is in effect
// from here, and start_gateway_authentication above only saw the local file.
// Without one the two are the same configuration and it already ran.
if config.controller.is_some() {
gateway_oidc::start_device_login_if_configured(&initial_config, &args.state_dir);
gateway_oidc::start_device_login_if_configured(initial_config, &args.state_dir);
}
} else {
tracing::info!(
"preserving managed files until the controller provides daemon configuration"
);
}
// The current configuration, re-applied by the reconcile tick; held here
// for the daemon's lifetime and replaced by each pushed configuration.
let (current_config, current_config_receiver) =
watch::channel(initial_config.map(|config| crate::tick::CurrentConfig {
revision: None,
config: Arc::new(config),
}));
let (tick_status_sender, tick_status_receiver) = watch::channel(None);
let tick_enabled = args.reconcile_interval.is_some();
if let Some(interval) = args.reconcile_interval {
tracing::info!(
interval_seconds = interval.as_secs_f64(),
"reconcile tick enabled"
);
tokio::spawn(crate::tick::run_tick(
reconciler.clone(),
current_config_receiver,
interval,
config.controller.is_some().then_some(tick_status_sender),
));
}
let discovery = reconciler.discover().await;
log_discovery(&discovery);
let (inventory_sender, inventory) = watch::channel(Arc::new(discovery));
Expand All @@ -460,6 +533,7 @@ where
let (logout_sender, logout_receiver) = mpsc::channel(1);
let logout = config.controller.as_ref().map(|_| logout_sender);
if let Some(controller) = config.controller.clone() {
let remote_current_config = current_config.clone();
let remote_discovery = inventory.clone();
let state_dir = args.state_dir.clone();
let oidc_callback_listen = args.oidc_callback_listen;
Expand All @@ -475,6 +549,8 @@ where
remote::Requests {
telemetry: telemetry_receiver,
logout: logout_receiver,
current: remote_current_config,
tick_statuses: tick_enabled.then_some(tick_status_receiver),
},
));
}
Expand Down Expand Up @@ -560,7 +636,7 @@ fn attach_llm_proxy_pairing(
/// failure is logged and reported as `bound: false` rather than propagated:
/// the proxy is optional, the rest of the daemon is not.
///
/// Invariant for callers: this runs before the initial `reconciler.apply`, so a
/// Invariant for callers: this runs before the initial apply, so a
/// reconciler that writes the proxy address into a client file must take the
/// address from this result and must not write it when `bound` is false. The
/// accept loop starts later, after discovery; connections in between queue in
Expand Down Expand Up @@ -856,6 +932,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 +972,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 +1703,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 +1798,69 @@ 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 the_reconcile_tick_is_off_unless_an_interval_is_set() {
let resolved = daemon_args(true)
.resolve(
config::DaemonStartupConfig::default(),
PathBuf::from("config.yaml"),
PathBuf::from("agentdesktop.sock"),
)
.unwrap();
assert_eq!(resolved.reconcile_interval, None);
let startup = config::DaemonStartupConfig {
reconcile_interval: Some(Duration::from_secs(120)),
..Default::default()
};
let resolved = daemon_args(true)
.resolve(
startup,
PathBuf::from("config.yaml"),
PathBuf::from("agentdesktop.sock"),
)
.unwrap();
assert_eq!(resolved.reconcile_interval, Some(Duration::from_secs(120)));
}

#[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