diff --git a/Cargo.lock b/Cargo.lock index d8ba3125..75cf6a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,7 @@ dependencies = [ "bytes", "clap", "futures-util", + "glob", "hostname", "http-body-util", "hyper", diff --git a/README.md b/README.md index 351885f8..4fb224fa 100644 --- a/README.md +++ b/README.md @@ -683,7 +683,7 @@ device-wide state. The device page in the controller lists them under | `unchanged` | The program is configured and nothing needed to change. | | `removed` | The program is no longer configured and its managed content was removed. | | `conflict` | A managed file holds configuration the daemon will not overwrite; the detail names the file. | -| `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; the detail says when no entries were in place, so the tool keeps its own settings) or removed (`failOpen`). | +| `inactive` | The daemon's mode cannot manage the program, so its files are untouched and the detail names the required mode. Also used when 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; the detail says when no entries were in place, so the tool keeps its own settings) or removed (`failOpen`). | | `blocked` | The program had changes, but none were written because another program conflicted or failed; the detail names that program. | | `failed` | Planning or writing this program failed; the detail carries the error. | @@ -830,13 +830,20 @@ runs this setup. | Tool | Discovery | Managed configuration | MCP and skills inventory | Sandbox policy | | --- | --- | --- | --- | --- | | Claude Code | Yes | Yes | MCP and skills | Yes | -| Claude Desktop | Yes | Yes | MCP | — | +| Claude Desktop | Yes | System mode | MCP | — | | Codex | Yes | Yes | MCP and skills | Yes | | Cursor | Yes | — | MCP and skills | — | | OpenCode | Yes | Yes | MCP | — | | VS Code | Yes | User mode (Copilot Chat) | MCP and skills | — | | Grok Build | Yes | System mode | MCP and skills | — | | GitHub Copilot CLI | Yes | User mode | — | — | +| Pi | Yes | User mode | MCP and skills | — | + +A fleet policy can include programs for both user and system mode. Each daemon +skips programs its mode cannot manage and reports them as **inactive** with a +reason; supported programs continue applying. Skipped programs' files are left +untouched, including when the program is removed from the policy. Cleanup requires +a daemon running in the program's supported mode. > **Don't see your tool?** We're actively expanding this list and would love > your help. [Open an integration request](https://github.com/agentdesktop-dev/agentdesktop/issues/new) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 695324a8..5ae22cab 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -30,6 +30,7 @@ axum.workspace = true base64.workspace = true bytes.workspace = true clap.workspace = true +glob = "0.3" hostname.workspace = true hyper.workspace = true hyper-rustls.workspace = true diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 300632eb..9abd00e2 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -1,7 +1,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use axum::{ - Json, Router, + Extension, Json, Router, extract::{Query, State}, http::StatusCode, routing::{get, post}, @@ -66,6 +66,9 @@ pub fn router(state: AppState) -> Router { .route("/v1/llm-gateway/credential", get(llm_gateway_credential)) .route("/v1/llm-gateway/login", post(llm_gateway_login)) .route("/v1/usage", get(device_usage)) + .layer(Extension(Arc::new( + crate::llm_proxy::CredentialCache::default(), + ))) .with_state(state) } @@ -241,6 +244,7 @@ async fn logout(State(state): State) -> Result, + Extension(cache): Extension>, Query(query): Query, ) -> Result, (StatusCode, String)> { if !valid_client_id(&query.client_id) { @@ -252,6 +256,28 @@ async fn llm_gateway_credential( format!("read applied configuration: {error:#}"), ) })?; + // Pi executes its helper on every request. Reuse the proxy's 60 s cache + // for controller identities; OIDC and subscription flows keep their own + // refresh behavior. Read identity.json on each call so logout/re-enrollment + // cannot reuse another device's token. + if !program_uses_subscription(&effective, &query.client_id) + && let Some(gateway) = effective.llm_gateway.as_ref() + && let Some(LlmGatewayAuthentication::ControllerJwt { + audience, + allowed_client_ids, + }) = &gateway.authentication + && allowed_client_ids.contains(&query.client_id) + && let Ok(Some(device_id)) = + crate::identity::load_device_id(&state.state_dir.join("identity.json")) + { + let key = format!("{}\0{}\0{audience}", query.client_id, gateway.url); + return cache + .get_credential(&key, &device_id, || { + gateway_credential(&state, &effective, &query.client_id, true) + }) + .await + .map(Json); + } gateway_credential(&state, &effective, &query.client_id, true) .await .map(Json) @@ -511,6 +537,190 @@ mod tests { use super::{load_effective_config, program_uses_subscription, unmetered_tools}; + #[tokio::test] + async fn credential_endpoint_reuses_controller_tokens_only_for_the_same_identity_and_policy() { + use agentdesktop_core::model::{DaemonInfo, DaemonScope, Discovery, LlmGatewayCredential}; + use axum::{ + Extension, + extract::{Query, State}, + }; + use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + let root = tempfile::tempdir().unwrap(); + let config = parse_daemon("llmGateway:\n url: https://gateway.example.com\n authentication:\n type: controllerJwt\n audience: test\n allowedClientIds: [pi, codex]\n").unwrap(); + let identity = |id: &str| { + serde_json::json!({ + "deviceId": id, "clientCertificatePem": "test", "clientCertificateExpiresAtUnixSeconds": 0, + "oauthTokenEndpoint": "test", "oauthClientId": "test" + }) + }; + fs::write( + root.path().join("identity.json"), + identity("device-1").to_string(), + ) + .unwrap(); + let (_, discovery) = tokio::sync::watch::channel(Arc::new(Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let mut state = super::AppState { + config, + daemon_info: DaemonInfo { + version: "test".into(), + scope: DaemonScope::User, + config_path: String::new(), + state_directory: String::new(), + inventory_interval: Duration::from_secs(60), + controller: None, + llm_proxy: None, + usage_receiver: None, + }, + discovery, + enrollment: crate::enrollment::EnrollmentState::new(false), + controller_status: None, + state_dir: root.path().to_owned(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + usage: None, + }; + let cache = Arc::new(crate::llm_proxy::CredentialCache::default()); + let expiry = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 600; + cache + .get_credential( + "pi\0https://gateway.example.com/\0test", + "device-1", + || async { + Ok::<_, ()>(LlmGatewayCredential { + credential: "cached-token".into(), + expires_at_unix_seconds: expiry, + }) + }, + ) + .await + .unwrap(); + for _ in 0..2 { + let credential = super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "pi".into(), + }), + ) + .await + .unwrap() + .0; + assert_eq!(credential.credential, "cached-token"); + assert_eq!(credential.expires_at_unix_seconds, expiry); + } + // Policy changes must not reuse a cached token for the old policy. + if let Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { + audience, + .. + }) = &mut state.config.llm_gateway.as_mut().unwrap().authentication + { + *audience = "changed".into(); + } + assert!( + super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "pi".into() + }) + ) + .await + .is_err() + ); + if let Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { + audience, + allowed_client_ids, + }) = &mut state.config.llm_gateway.as_mut().unwrap().authentication + { + *audience = "test".into(); + allowed_client_ids.remove("pi"); + } + assert!( + super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "pi".into() + }) + ) + .await + .is_err() + ); + if let Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { + allowed_client_ids, + .. + }) = &mut state.config.llm_gateway.as_mut().unwrap().authentication + { + allowed_client_ids.insert("pi".into()); + } + // No controller is configured, so any miss fails instead of contacting a server. + assert!( + super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "codex".into() + }) + ) + .await + .is_err() + ); + state.config.llm_gateway.as_mut().unwrap().url = + "https://other.example.com".parse().unwrap(); + assert!( + super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "pi".into() + }) + ) + .await + .is_err() + ); + state.config.llm_gateway.as_mut().unwrap().url = + "https://gateway.example.com".parse().unwrap(); + fs::write( + root.path().join("identity.json"), + identity("device-2").to_string(), + ) + .unwrap(); + assert!( + super::llm_gateway_credential( + State(state.clone()), + Extension(cache.clone()), + Query(super::CredentialQuery { + client_id: "pi".into() + }) + ) + .await + .is_err() + ); + fs::remove_file(root.path().join("identity.json")).unwrap(); + assert!( + super::llm_gateway_credential( + State(state), + Extension(cache), + Query(super::CredentialQuery { + client_id: "pi".into() + }) + ) + .await + .is_err() + ); + } + #[test] fn vscode_on_github_models_is_reported_as_unmetered() { let github_models = parse_daemon( diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index 58c2d4f4..ff9dc961 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -92,6 +92,7 @@ struct ResolvedDaemonArgs { vscode_chat_models: Option, /// VS Code's user `settings.json`; `None` in system mode (user-only program). vscode_settings: Option, + pi: ResolvedPiStartupConfig, /// `daemon.reconcileInterval`; `None` means no periodic re-apply. reconcile_interval: Option, once: bool, @@ -112,6 +113,11 @@ struct ResolvedOpenCodeStartupConfig { plugin: PathBuf, } +struct ResolvedPiStartupConfig { + models: Option, + settings: Option, +} + impl DaemonArgs { fn resolve( self, @@ -220,6 +226,15 @@ impl DaemonArgs { }, vscode_chat_models: None, vscode_settings: None, + pi: { + if startup.pi.models.is_some() || startup.pi.settings.is_some() { + bail!("daemon.pi paths require --user (or daemon.user: true)"); + } + ResolvedPiStartupConfig { + models: None, + settings: None, + } + }, reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, @@ -308,6 +323,14 @@ impl DaemonArgs { .settings .unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)), ), + pi: ResolvedPiStartupConfig { + models: Some(startup.pi.models.unwrap_or_else(|| { + crate::provider::pi::user_pi_agent_dir(&home).join("models.json") + })), + settings: Some(startup.pi.settings.unwrap_or_else(|| { + crate::provider::pi::user_pi_agent_dir(&home).join("settings.json") + })), + }, reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, @@ -433,6 +456,8 @@ where args.copilot_providers.clone(), args.vscode_chat_models.clone(), args.vscode_settings.clone(), + args.pi.models.clone(), + args.pi.settings.clone(), agentdesktop_client_executable()?, socket.clone(), ) @@ -444,7 +469,7 @@ where .dry_run(&config) .context("preview daemon configuration")?; } else { - validate_one_shot(&config)?; + validate_one_shot(&config, args.user)?; let (report, applied) = reconciler.apply_with_report(&config); report.log(); applied.context("apply daemon configuration")?; @@ -467,6 +492,7 @@ where }; start_gateway_authentication( &config, + args.user, args.state_dir.clone(), args.oidc_callback_listen, proxy_listener.is_some(), @@ -915,8 +941,26 @@ async fn refresh_inventory_with( } } +/// Only programs managed in this mode can request subscription sign-in. +fn uses_subscription(config: &agentdesktop_core::config::DaemonConfig, user: bool) -> bool { + use agentdesktop_core::config::ProgramAuthentication; + + config + .programs + .claude_code + .as_ref() + .is_some_and(|program| program.auth == Some(ProgramAuthentication::Subscription)) + || (!user + && config + .programs + .claude_desktop + .as_ref() + .is_some_and(|program| program.auth == Some(ProgramAuthentication::Subscription))) +} + fn start_gateway_authentication( config: &agentdesktop_core::config::DaemonConfig, + user: bool, state_dir: PathBuf, callback_listen: Option, proxy_enabled: bool, @@ -926,15 +970,7 @@ fn start_gateway_authentication( }; let authentication = gateway.authentication.clone(); let github = gateway.github_oauth.clone().filter(|_| proxy_enabled); - let subscription = config.programs.claude_code.as_ref().is_some_and(|program| { - program.auth == Some(agentdesktop_core::config::ProgramAuthentication::Subscription) - }) || config - .programs - .claude_desktop - .as_ref() - .is_some_and(|program| { - program.auth == Some(agentdesktop_core::config::ProgramAuthentication::Subscription) - }); + let subscription = uses_subscription(config, user); if authentication.is_none() && !subscription { return; } @@ -1033,7 +1069,10 @@ fn validate_dry_run(config: &agentdesktop_core::config::DaemonConfig) -> anyhow: Ok(()) } -fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow::Result<()> { +fn validate_one_shot( + config: &agentdesktop_core::config::DaemonConfig, + user: bool, +) -> anyhow::Result<()> { if config.controller.is_some() { bail!( "--once cannot use a controller because controller synchronization requires the daemon to remain running" @@ -1042,12 +1081,12 @@ 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() { + if user && 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() { + if user && 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" ); @@ -1062,11 +1101,12 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow .claude_code .as_ref() .is_some_and(|program| program.use_llm_gateway), - config - .programs - .claude_desktop - .as_ref() - .is_some_and(|program| program.use_llm_gateway), + !user + && config + .programs + .claude_desktop + .as_ref() + .is_some_and(|program| program.use_llm_gateway), config .programs .codex @@ -1077,19 +1117,15 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow .open_code .as_ref() .is_some_and(|program| program.use_llm_gateway), - config - .programs - .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 + !user + && config + .programs + .grok + .as_ref() + .is_some_and(|program| program.use_llm_gateway), + user && config .programs - .vscode + .pi .as_ref() .is_some_and(|program| program.use_llm_gateway), ] @@ -1345,6 +1381,51 @@ mod tests { use agentdesktop_core::model::{Agent, Discovery, LlmProxyInfo}; use tokio::sync::{mpsc, watch}; + #[test] + fn pi_startup_paths_require_user_mode() { + for user in [false, true] { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + format!( + "daemon:\n user: {user}\n pi:\n models: custom/pi-models.json\n settings: custom/pi-settings.json\n" + ), + ) + .unwrap(); + let config = agentdesktop_core::config::load_daemon(file.path()).unwrap(); + let args = super::DaemonArgs { + user: false, + once: false, + dry_run: false, + config: None, + }; + let result = args.resolve( + config.daemon.unwrap(), + file.path().to_path_buf(), + agentdesktop_core::DEFAULT_SOCKET_PATH.into(), + ); + if !user { + assert!( + result + .err() + .unwrap() + .to_string() + .contains("daemon.pi paths require --user") + ); + continue; + } + let resolved = result.unwrap(); + assert_eq!( + resolved.pi.models.as_deref(), + Some(Path::new("custom/pi-models.json")) + ); + assert_eq!( + resolved.pi.settings.as_deref(), + Some(Path::new("custom/pi-settings.json")) + ); + } + } + fn discovery(kinds: &[&str]) -> Discovery { Discovery { agents: kinds @@ -1949,6 +2030,67 @@ programs: ); } + #[test] + fn skipped_desktop_does_not_request_subscription_sign_in() { + let mut config = parse_daemon( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n pi:\n model: selected\n claudeDesktop:\n auth: subscription\n" + ).unwrap(); + assert!(!super::uses_subscription(&config, true)); + assert!(super::uses_subscription(&config, false)); + + config.programs.claude_code = + Some(serde_json::from_value(serde_json::json!({ "auth": "subscription" })).unwrap()); + assert!(super::uses_subscription(&config, true)); + assert!(super::uses_subscription(&config, false)); + } + + #[test] + fn one_shot_rejects_authenticated_pi_gateway() { + for authentication in [ + " type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\n", + " type: controllerJwt\n audience: agentgateway\n allowedClientIds: [pi]\n", + ] { + let config = parse_daemon(&format!( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n{authentication}programs:\n pi:\n model: selected\n" + )).unwrap(); + let error = validate_one_shot(&config, true) + .expect_err("Pi credentials require a running daemon"); + assert!(error.to_string().contains("credential helpers")); + } + } + + #[test] + fn one_shot_accepts_pi_without_runtime_credentials() { + for yaml in [ + "programs:\n pi: {}\n", + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n", + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n pi:\n useLlmGateway: false\n", + ] { + validate_one_shot(&parse_daemon(yaml).unwrap(), true).unwrap(); + } + } + + #[test] + fn one_shot_ignores_runtime_requirements_of_skipped_programs() { + for (user, programs) in [ + ( + false, + " pi:\n model: selected\n copilot:\n models:\n selected: {}\n vscode:\n models:\n selected: {}\n", + ), + ( + true, + " claudeDesktop:\n model: selected\n grok:\n model: selected\n", + ), + ] { + let config = parse_daemon(&format!( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n claudeCode:\n useLlmGateway: false\n{programs}" + )).unwrap(); + validate_one_shot(&config, user).expect("skipped programs need no runtime services"); + validate_one_shot(&config, !user) + .expect_err("supported programs still require runtime services"); + } + } + #[test] fn one_shot_accepts_static_settings_and_rejects_runtime_services() { let static_config = parse_daemon( @@ -1960,7 +2102,7 @@ programs: "#, ) .unwrap(); - validate_one_shot(&static_config).expect("static settings work in one-shot mode"); + validate_one_shot(&static_config, true).expect("static settings work in one-shot mode"); let oidc = parse_daemon( r#" @@ -1976,7 +2118,7 @@ programs: ) .unwrap(); assert!( - validate_one_shot(&oidc) + validate_one_shot(&oidc, true) .unwrap_err() .to_string() .contains("credential helpers") @@ -1990,7 +2132,7 @@ telemetry: ) .unwrap(); assert!( - validate_one_shot(&telemetry) + validate_one_shot(&telemetry, true) .unwrap_err() .to_string() .contains("telemetry") @@ -2011,7 +2153,7 @@ programs: ) .unwrap(); assert!( - validate_one_shot(&copilot) + validate_one_shot(&copilot, true) .unwrap_err() .to_string() .contains("Copilot") @@ -2032,7 +2174,7 @@ programs: ) .unwrap(); assert!( - validate_one_shot(&vscode) + validate_one_shot(&vscode, true) .unwrap_err() .to_string() .contains("VS Code") diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 22f630a7..4516ba28 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -49,7 +49,7 @@ const CREDENTIAL_EXPIRY_MARGIN: Duration = Duration::from_secs(30); /// revocation is enforced at issuance, the gateway validates tokens statelessly. const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(60); -/// Listener-local cache of controller-issued gateway credentials, one per client +/// Per-server cache of controller-issued gateway credentials, one per client /// id. A controller fetch is a fresh mTLS connection and round trip; agent-mode /// sessions issue bursts of requests, so a short cache keeps them off the /// controller. Fetches are single-flight per key: concurrent misses wait for @@ -107,6 +107,25 @@ fn now_unix() -> u64 { } impl CredentialCache { + /// Reuse the proxy's bounded cache for local credential-helper requests. + pub(crate) async fn get_credential( + &self, + key: &str, + device_id: &str, + fetch: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: + std::future::Future>, + { + let (credential, expiry, _) = self.get_or_fetch(key, device_id, fetch).await?; + Ok(agentdesktop_core::model::LlmGatewayCredential { + credential, + expires_at_unix_seconds: expiry.unix, + }) + } + /// The lock a fetch for the key holds; one per key, kept for the /// listener's lifetime (keys are client id, gateway and audience). fn flight(&self, key: &str) -> Arc> { diff --git a/crates/agent/src/provider/README.md b/crates/agent/src/provider/README.md index 7049dde6..32c573ac 100644 --- a/crates/agent/src/provider/README.md +++ b/crates/agent/src/provider/README.md @@ -1,6 +1,14 @@ # Provider support -Features currently implemented by Agentdesktop. +Features currently implemented by agentdesktop. + +A fleet policy can include programs for both daemon modes. Programs the current +mode cannot manage are skipped and reported as **inactive**, with a reason in +configuration status and dry-run output. Supported programs continue applying. +Skipped programs' files and ownership metadata are left untouched, even when +those programs are removed from the policy. Cleanup runs in the mode that manages +the program. Claude Desktop and Grok Build require system mode; Pi, Copilot CLI, +and VS Code require user mode. VS Code managed configuration (Copilot Chat custom models in the user's `chatLanguageModels.json`, or the Copilot Chat API override in the user's @@ -10,28 +18,73 @@ these files point at the daemon's loopback LLM proxy. The Copilot CLI's MCP and `~/.copilot` is read by the VS Code adapter. Grok Build managed configuration requires system mode. Linux and macOS use `/etc/grok/managed_config.toml`; Windows uses the system drive's -`\etc\grok\managed_config.toml`. `--user` rejects `programs.grok` because +`\etc\grok\managed_config.toml`. User daemons skip `programs.grok` because Grok's own configuration sync can delete or replace the user-level managed file. +Pi stores configuration in `~/.pi/agent` (or `PI_CODING_AGENT_DIR`, with `~` +expanded to the user's home). Management requires `--user`; system daemons +skip `programs.pi` because Pi does not read `/etc/pi/agent`. In user mode, +authenticated gateway configuration also requires a running daemon and +rejects `--once`. +Pi executes its credential helper on every request. The daemon caches +controller-issued credentials for up to 60 seconds per allowed client, +gateway, audience, and enrolled device, with a 30-second expiry margin and +single-flight fetches. OIDC and subscription flows retain their own refresh +behavior. This removes repeated controller round trips; Pi still launches +the helper for each request. +User mode merges `providers.agentdesktop` into `models.json` and sets the +default model in `settings.json`. Model map keys determine catalog IDs, and +gateway URLs override per-model endpoints, using each model's API dialect. +The gateway owns the entire `providers.agentdesktop.models` catalog while +enabled; stale entries are replaced, and the prior catalog is restored on +cleanup. Other providers and user additions are preserved. On Unix, +`models.json` is written with owner-only permissions (`0600`), and +reconciliation repairs a looser mode on the managed file; cleanup keeps the +file's current mode. Dry runs report the `models.json` action and path +without its contents, which may include the user's own API keys. +Commented `models.json` files are accepted and rewritten as standard JSON, +preserving user configuration values. `settings.json` requires standard JSON. +Discovery recognizes npm's Windows command shims as well as symlinked launchers, +verifying the Pi package manifest without executing the launcher. +MCP inventory defaults to Pi's built-in user `mcp.json` and the working +directory's `.pi/mcp.json` only when saved project trust (or +`defaultProjectTrust: always`) permits it. Session-only trust cannot be +inferred. Disabling `builtin:mcp` excludes these sources. When an enabled, +installed `pi-mcp-adapter` is detected in Pi's package or extension settings, +or its auto-loaded extension directories, discovery instead uses adapter files +(`~/.pi/agent/mcp.json`, the working directory's `.pi/mcp.json` and +`.mcp.json`, and shared `~/.config/mcp/mcp.json` / `~/.agents/mcp.json` / +`~/.agents/mcp/mcp.json`). Ancestor project files are included only when a +user-global config sets `settings.ancestorConfigRoots` to an existing +directory under the home that contains the working directory. Both `.agents` +paths are supported by the [adapter's configuration loader](https://github.com/nicobailon/pi-mcp-adapter/blob/33bdc38d8dd3802f2b51ba1fe37e30ed7ae6a29a/config.ts#L15-L19). +`PI_MCP_CONFIG_MODE=exclusive` applies only to the adapter and inventories +only the Pi agent `mcp.json`. +Skill inventory reads `~/.pi/agent/skills`, `~/.agents/skills`, the working +directory's `.pi/skills`, and `.agents/skills` from the working directory +through the git repository root. +Host-specific Cursor/Claude +files are not scanned until the adapter imports them into a Pi-owned file. + ✅ Implemented · ◯ Not implemented · — Not applicable -| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | -| Local model discovery | — | — | — | — | — | ✅ | — | — | — | -| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | -| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | -| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | -| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | -| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | -| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | -| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | -| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | - -- ◯ describes a gap in Agentdesktop; it does not mean the upstream provider +| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | [Pi](pi/) | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | +| Local model discovery | — | — | — | — | — | ✅ | — | — | — | — | +| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ | +| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ | +| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | ✅ | +| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ | +| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ | +| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | +| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | +| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | + +- ◯ describes a gap in agentdesktop; it does not mean the upstream provider cannot support the feature. -- Managed configuration includes dry runs and removal of Agentdesktop-owned +- Managed configuration includes dry runs and removal of agentdesktop-owned settings (no dry run for the GitHub Copilot CLI providers file and the VS Code chat language models and settings files: their loopback proxy cannot run in one-shot mode). Claude Code also supports merging into user settings; Claude Desktop diff --git a/crates/agent/src/provider/claude_desktop/mod.rs b/crates/agent/src/provider/claude_desktop/mod.rs index e6a09e58..1b0bd227 100644 --- a/crates/agent/src/provider/claude_desktop/mod.rs +++ b/crates/agent/src/provider/claude_desktop/mod.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use agentdesktop_core::config::DaemonConfig; -use agentdesktop_core::model::Discovery; +use agentdesktop_core::model::{DaemonScope, Discovery}; use super::{Provider, ReconcileContext}; use crate::reconcile::ReconcilePlan; @@ -45,10 +45,12 @@ impl Provider for ClaudeDesktop { } fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { - if ctx.merge_user_settings && config.programs.claude_desktop.is_some() { - anyhow::bail!( - "Claude Desktop does not read inference settings from its user preferences; remove programs.claudeDesktop or run Agentdesktop without --user as root so it can manage its Claude Desktop managed settings" - ); + if let Some(plan) = ctx.skip_unless( + DaemonScope::System, + config.programs.claude_desktop.is_some(), + Self::DISPLAY_NAME, + ) { + return Ok(plan); } let configured = config.programs.claude_desktop.as_ref().map(|provider| { let gateway = config diff --git a/crates/agent/src/provider/copilot/mod.rs b/crates/agent/src/provider/copilot/mod.rs index c461b9a3..c406968a 100644 --- a/crates/agent/src/provider/copilot/mod.rs +++ b/crates/agent/src/provider/copilot/mod.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use agentdesktop_core::config::DaemonConfig; -use agentdesktop_core::model::Discovery; +use agentdesktop_core::model::{DaemonScope, Discovery}; use super::{Provider, ReconcileContext}; use crate::reconcile::ReconcilePlan; @@ -52,6 +52,14 @@ impl Provider for Copilot { } fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + if let Some(plan) = ctx.skip_unless( + DaemonScope::User, + config.programs.copilot.is_some(), + DISPLAY_NAME, + ) { + return Ok(plan); + } + let configured = config.programs.copilot.as_ref().map(|program| { let gateway = config .llm_gateway @@ -64,18 +72,9 @@ impl Provider for Copilot { .as_ref() .map(|proxy| (proxy.address, &*proxy.pairing)); let plan = ReconcilePlan::default(); - // The inverse of Grok's check: the file lives in the user's home, so a - // system daemon has no providers path and rejects the program before - // any provider writes. - let Some(path) = self - .providers_path - .as_deref() - .filter(|_| ctx.merge_user_settings) - else { + let Some(path) = self.providers_path.as_deref() else { if configured.is_some() { - anyhow::bail!( - "{DISPLAY_NAME} reads its providers from the user's own Copilot directory; remove programs.copilot or run agentdesktop with --user (daemon.user: true) so it can manage that file" - ); + anyhow::bail!("{DISPLAY_NAME} user configuration path is unavailable"); } return Ok(plan); }; diff --git a/crates/agent/src/provider/copilot/reconcile.rs b/crates/agent/src/provider/copilot/reconcile.rs index 63a108b3..33fa84d0 100644 --- a/crates/agent/src/provider/copilot/reconcile.rs +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -81,6 +81,7 @@ fn options() -> json_merge::MergeOptions { keyed_arrays: KEYED, // The file holds the pairing and possibly the user's own API keys. redact_diff: true, + ..json_merge::MergeOptions::default() } } diff --git a/crates/agent/src/provider/copilot/tests.rs b/crates/agent/src/provider/copilot/tests.rs index 4de39f1c..f4f47437 100644 --- a/crates/agent/src/provider/copilot/tests.rs +++ b/crates/agent/src/provider/copilot/tests.rs @@ -752,10 +752,10 @@ fn conflict_on_a_non_object_file() { assert_eq!(fs::read(&path).unwrap(), b"[]\n"); } -// --- Provider::plan: system-mode rejection ---------------------------------- +// --- Provider::plan: system-mode skipping ---------------------------------- #[test] -fn system_mode_rejects_a_configured_program_before_writing_anything() { +fn system_mode_skips_a_configured_program_without_writing_anything() { use super::Copilot; use crate::provider::{Provider, ReconcileContext}; let dir = tempfile::tempdir().unwrap(); @@ -779,12 +779,15 @@ fn system_mode_rejects_a_configured_program_before_writing_anything() { providers_path: Some(path.clone()), }, ] { - let error = match provider.plan(&context(false), &config) { - Ok(_) => panic!("system mode with programs.copilot configured must fail"), - Err(error) => error, - }; - assert!(error.to_string().contains("--user"), "{error}"); - assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); + let plan = provider + .plan(&context(false), &config) + .expect("system mode skips this program"); + let reason = plan + .inactive_reason() + .expect("configured program is inactive"); + assert!(reason.contains("--user"), "{reason}"); + assert!(reason.contains("GitHub Copilot CLI"), "{reason}"); + plan.apply().unwrap(); } assert!(!path.exists(), "preflight failure must not write any files"); // System mode with the program absent does nothing. diff --git a/crates/agent/src/provider/grok/mod.rs b/crates/agent/src/provider/grok/mod.rs index b91e24e5..522ee8ab 100644 --- a/crates/agent/src/provider/grok/mod.rs +++ b/crates/agent/src/provider/grok/mod.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use agentdesktop_core::config::DaemonConfig; -use agentdesktop_core::model::Discovery; +use agentdesktop_core::model::{DaemonScope, Discovery}; use super::{Provider, ReconcileContext}; use crate::reconcile::ReconcilePlan; @@ -41,10 +41,12 @@ impl Provider for Grok { } fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { - if ctx.merge_user_settings && config.programs.grok.is_some() { - anyhow::bail!( - "Grok Build can delete or replace its user-level managed_config.toml during startup; remove programs.grok or run agentdesktop in system mode so it can manage Grok's system managed_config.toml" - ); + if let Some(plan) = ctx.skip_unless( + DaemonScope::System, + config.programs.grok.is_some(), + Self::DISPLAY_NAME, + ) { + return Ok(plan); } let configured = config.programs.grok.as_ref().map(|provider| { let gateway = config diff --git a/crates/agent/src/provider/json_merge.rs b/crates/agent/src/provider/json_merge.rs index eecf7d27..01fa94a6 100644 --- a/crates/agent/src/provider/json_merge.rs +++ b/crates/agent/src/provider/json_merge.rs @@ -17,6 +17,29 @@ struct MergeState { after: Value, } +/// How an existing user file is parsed. The merged file is always written as +/// standard JSON. +#[derive(Clone, Copy)] +pub(super) enum JsonFormat { + Json, + /// JSON with comments and trailing commas; they are not kept when the + /// file is rewritten. + Jsonc, +} + +impl JsonFormat { + fn parse(&self, contents: &[u8]) -> anyhow::Result { + match self { + Self::Json => Ok(serde_json::from_slice(contents)?), + Self::Jsonc => { + let contents = std::str::from_utf8(contents)?; + // Normalize commented input to ordinary JSON when writing it. + Ok(json5::from_str(contents.trim_start_matches('\u{feff}'))?) + } + } + } +} + pub(super) fn state_path(path: &Path) -> PathBuf { let name = path .file_name() @@ -48,6 +71,11 @@ pub(super) struct MergeOptions { /// Record actions without file content, for a file that holds secrets: /// the dry-run report then shows the action and the path only. pub(super) redact_diff: bool, + /// How the existing file is parsed. + pub(super) format: JsonFormat, + /// JSON pointers to values managed as a whole, rather than merged + /// additively; removal restores the user's original value. + pub(super) replace_paths: &'static [&'static str], } impl Default for MergeOptions { @@ -56,6 +84,8 @@ impl Default for MergeOptions { mode: 0o644, keyed_arrays: &[], redact_diff: false, + format: JsonFormat::Json, + replace_paths: &[], } } } @@ -130,11 +160,12 @@ pub(super) fn plan_merge_with( } }; // An empty (or whitespace-only) file holds nothing to keep and is not a - // conflict; the merge fills it. Comments or trailing commas are: the file - // is rewritten as plain JSON, so they would be lost silently. + // conflict; the merge fills it. Comments or trailing commas are, unless + // `options.format` accepts them: the file is rewritten as plain JSON, so + // they would be lost silently. let mut combined = match existing.as_deref() { Some(contents) if contents.iter().all(u8::is_ascii_whitespace) => empty_root(), - Some(contents) => match serde_json::from_slice::(contents) { + Some(contents) => match options.format.parse(contents) { Ok(value) if value.is_object() == managed.is_object() && value.is_array() == managed.is_array() => @@ -165,6 +196,14 @@ pub(super) fn plan_merge_with( remove_keyed(&mut combined, keyed, &managed); } let before = combined.clone(); + // Snapshot the user's original values before replacing any owned subtree. + for pointer in options.replace_paths { + if managed.pointer(pointer).is_some() + && let Some(existing) = combined.pointer_mut(pointer) + { + *existing = Value::Null; + } + } merge_overlay(&mut combined, managed); let mut contents = serde_json::to_vec_pretty(&combined) @@ -260,7 +299,7 @@ pub(super) fn plan_remove_with( remove_file(state_path, display_name, plan)?; return Ok(true); } - let settings = match serde_json::from_slice::(&existing) { + let settings = match options.format.parse(&existing) { Ok(value) if value.is_object() == state.after.is_object() && value.is_array() == state.after.is_array() => @@ -451,6 +490,33 @@ fn rollback_value( let after = after .and_then(Value::as_array) .map_or(&[][..], Vec::as_slice); + // Additive merges keep the original array as a prefix. A replacement + // must restore its order and duplicate entries, then retain user edits. + if !after.starts_with(before) { + let mut result = before.to_vec(); + let mut restored = before.to_vec(); + let mut added = current_array.clone(); + for value in after { + if let Some(index) = restored.iter().position(|entry| entry == value) { + restored.remove(index); + } + if let Some(index) = added.iter().position(|entry| entry == value) { + added.remove(index); + } else if let Some(index) = result.iter().position(|entry| entry == value) { + // Respect a user's removal of a retained original entry. + result.remove(index); + } + } + for value in added { + if let Some(index) = restored.iter().position(|entry| entry == &value) { + // An original entry restored by the user is already present. + restored.remove(index); + } else { + result.push(value); + } + } + return Some(Value::Array(result)); + } let mut result = current_array.clone(); for added in after.iter().filter(|value| !before.contains(value)) { if let Some(index) = result.iter().position(|value| value == added) { @@ -718,6 +784,7 @@ mod tests { mode: 0o600, keyed_arrays: ROOT_KEYED, redact_diff: true, + ..MergeOptions::default() } } diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index b22310d3..a9b88cda 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -4,7 +4,10 @@ //! contributes no entries. Reconciliation is optional and only builds a plan. use std::path::PathBuf; -use agentdesktop_core::{config::DaemonConfig, model::Discovery}; +use agentdesktop_core::{ + config::DaemonConfig, + model::{DaemonScope, Discovery}, +}; use crate::reconcile::ReconcilePlan; @@ -18,6 +21,7 @@ mod json_merge; mod metadata; pub mod ollama; pub mod opencode; +pub mod pi; pub(crate) mod shared; pub mod vscode; @@ -54,3 +58,29 @@ pub struct ReconcileContext { /// or neutralise any pointer they wrote earlier, never leave a stale one. pub llm_proxy: Option, } + +impl ReconcileContext { + /// Skip providers outside this daemon's scope before they read or clean up + /// any files. Only configured programs need an inactive status. + fn skip_unless( + &self, + required_scope: DaemonScope, + configured: bool, + display_name: &str, + ) -> Option { + if self.merge_user_settings == (required_scope == DaemonScope::User) { + return None; + } + Some(if configured { + let (required, current) = match required_scope { + DaemonScope::User => ("user mode (--user)", "system mode"), + DaemonScope::System => ("system mode", "user mode"), + }; + ReconcilePlan::skipped(&format!( + "{display_name} requires {required}; skipped in {current}" + )) + } else { + ReconcilePlan::default() + }) + } +} diff --git a/crates/agent/src/provider/pi/discovery.rs b/crates/agent/src/provider/pi/discovery.rs new file mode 100644 index 00000000..a0e1a8b8 --- /dev/null +++ b/crates/agent/src/provider/pi/discovery.rs @@ -0,0 +1,1453 @@ +use std::{ + collections::BTreeSet, + env, fs, + io::Read, + path::{Path, PathBuf}, +}; + +use agentdesktop_core::model::{Agent, McpServer}; +use serde_json::Value; + +use crate::provider::{metadata, vscode::discovery as vscode}; + +use super::{Pi, user_pi_agent_dir}; + +pub(super) fn discover() -> Option { + let executable = metadata::find_all_in_path("pi") + .into_iter() + .chain( + executable_candidates() + .into_iter() + .filter(|candidate| candidate.is_file()), + ) + .find(|candidate| is_pi(candidate))?; + Some(Agent { + version: package_version(&executable), + executable, + kind: Pi::ID.to_owned(), + mcp_servers: discover_mcp_servers(), + skills: metadata::discover_skills(skill_roots()), + }) +} + +fn executable_candidates() -> Vec { + let mut candidates = BTreeSet::new(); + for home in metadata::user_home_dirs() { + candidates.insert(home.join(".local/bin/pi")); + candidates.insert(home.join(".npm-global/bin/pi")); + } + #[cfg(target_os = "macos")] + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/pi"), + PathBuf::from("/usr/local/bin/pi"), + ]); + #[cfg(target_os = "linux")] + candidates.extend([ + PathBuf::from("/usr/bin/pi"), + PathBuf::from("/usr/local/bin/pi"), + ]); + #[cfg(windows)] + { + windows_executable_candidates(candidates) + } + #[cfg(not(windows))] + candidates.into_iter().collect() +} + +#[cfg(any(windows, test))] +fn windows_executable_candidates(mut candidates: BTreeSet) -> Vec { + for home in metadata::user_home_dirs() { + candidates.extend([ + home.join(".local/bin/pi.exe"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Roaming/npm/pi.ps1"), + home.join("AppData/Local/pnpm/pi.exe"), + ]); + } + // PowerShell shims may be absent from PATHEXT; keep PATH order before fallbacks. + metadata::find_all_in_path("pi.ps1") + .into_iter() + .chain(candidates) + .collect() +} + +fn is_pi(executable: &Path) -> bool { + package_manifest(executable).is_some() +} + +fn package_version(executable: &Path) -> Option { + let manifest = package_manifest(executable)?; + metadata::json_package_version(&manifest, Pi::PACKAGE_NAME) +} + +fn package_manifest(executable: &Path) -> Option { + let mut seen = BTreeSet::new(); + for start in [ + Some(executable.to_path_buf()), + executable.canonicalize().ok(), + ] + .into_iter() + .flatten() + { + if let Some(manifest) = npm_shim_manifest(&start) { + return Some(manifest); + } + let mut directory = start.parent()?.to_path_buf(); + for _ in 0..8 { + let manifest = directory.join("package.json"); + if seen.insert(manifest.clone()) + && metadata::json_package_version(&manifest, Pi::PACKAGE_NAME).is_some() + { + return Some(manifest); + } + if !directory.pop() { + break; + } + } + } + None +} + +fn npm_shim_manifest(executable: &Path) -> Option { + let manifest = executable + .parent()? + .join("node_modules") + .join(Pi::PACKAGE_NAME) + .join("package.json"); + metadata::json_package_version(&manifest, Pi::PACKAGE_NAME)?; + + // npm's Windows global launchers point to a sibling package. Verify that + // relationship from the small launcher script without executing it. + const MAX_SHIM_BYTES: usize = 16 * 1024; + let file = std::fs::File::open(executable).ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut source = String::new(); + file.take((MAX_SHIM_BYTES + 1) as u64) + .read_to_string(&mut source) + .ok()?; + if source.len() > MAX_SHIM_BYTES { + return None; + } + + let target = format!("node_modules/{}/dist/bundle/cli.js\"", Pi::PACKAGE_NAME); + let basedir_target = format!("\"$basedir/{target}"); + let dp0_target = format!("\"%dp0%/{target}"); + source + .lines() + .any(|line| { + let line = line.trim_start().replace('\\', "/"); + ((line.starts_with("exec ") + || line.starts_with("& ") + || line.starts_with("$input | & ")) + && line.contains(&basedir_target)) + || (line.starts_with("endLocal & ") + && line.contains("\"%_prog%\"") + && line.contains(&dp0_target)) + }) + .then_some(manifest) +} + +fn discover_mcp_servers() -> Vec { + mcp_config_paths() + .into_iter() + .flat_map(|path| vscode::mcp_servers_from_json(&path)) + .collect() +} + +fn mcp_config_paths() -> Vec { + let current = env::current_dir().ok(); + mcp_config_paths_with( + &metadata::user_home_dirs(), + current.as_deref(), + pi_mcp_exclusive_mode(), + user_pi_agent_dir, + ) +} + +/// Built-in MCP reads the user file and the trusted project's `.pi/mcp.json`. +/// An enabled adapter replaces it and opts into the adapter's broader sources. +fn mcp_config_paths_with( + homes: &[PathBuf], + cwd: Option<&Path>, + exclusive: bool, + agent_dir: impl Fn(&Path) -> PathBuf, +) -> Vec { + let mut paths = BTreeSet::new(); + for home in homes { + let agent = agent_dir(home); + let settings = read_json(&agent.join("settings.json")).unwrap_or_default(); + let trusted = cwd.is_some_and(|cwd| project_trusted(&agent, cwd, &settings)); + if adapter_installed(home, &agent, cwd.filter(|_| trusted), &settings) { + paths.extend(adapter_mcp_config_paths_with( + std::slice::from_ref(home), + cwd, + exclusive, + &agent_dir, + )); + continue; + } + let project_settings = cwd + .filter(|_| trusted) + .and_then(|cwd| read_json(&cwd.join(".pi/settings.json"))) + .unwrap_or_default(); + let builtin_decision = |settings: &Value, project: bool| { + settings + .get("extensions") + .and_then(Value::as_array) + .and_then(|filters| { + if project { + ordered_overrides(Path::new("builtin:mcp"), Path::new(""), filters, false) + } else { + resource_enabled(Path::new("builtin:mcp"), Path::new(""), filters, false) + } + }) + }; + let builtin_enabled = builtin_decision(&project_settings, true) + .or_else(|| builtin_decision(&settings, false)) + .unwrap_or(true); + if builtin_enabled { + paths.insert(agent.join("mcp.json")); + if let Some(cwd) = cwd.filter(|_| trusted) { + paths.insert( + canonicalize_dir(cwd) + .unwrap_or_else(|| cwd.to_path_buf()) + .join(".pi/mcp.json"), + ); + } + } + } + paths.into_iter().collect() +} + +fn read_json(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + json5::from_str(contents.trim_start_matches('\u{feff}')).ok() +} + +/// Pi's nearest saved decision wins, then its user default. Session-only trust +/// and extension-provided decisions cannot be inferred from files. +fn project_trusted(agent: &Path, cwd: &Path, settings: &Value) -> bool { + let Some(cwd) = canonicalize_dir(cwd) else { + return false; + }; + if let Some(trust) = read_json(&agent.join("trust.json")) { + for path in cwd.ancestors() { + if let Some(decision) = trust.as_object().and_then(|entries| { + entries.iter().find_map(|(key, value)| { + // Canonicalize saved keys too: Windows canonical paths carry a + // verbatim prefix, while Pi's saved paths normally do not. + (canonicalize_dir(Path::new(key)).as_deref() == Some(path)) + .then(|| value.as_bool()) + .flatten() + }) + }) { + return decision; + } + } + } + settings.get("defaultProjectTrust").and_then(Value::as_str) == Some("always") +} + +fn adapter_installed(home: &Path, agent: &Path, project: Option<&Path>, settings: &Value) -> bool { + let project_dir = project.map(|p| p.join(".pi")); + let project_settings = project_dir + .as_ref() + .and_then(|p| read_json(&p.join("settings.json"))) + .unwrap_or_default(); + let mut packages: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let mut extensions = BTreeSet::new(); + for (base, settings) in std::iter::once((agent, settings)) + .chain(project_dir.as_deref().map(|base| (base, &project_settings))) + { + for entry in settings + .get("packages") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(source) = entry + .as_str() + .or_else(|| entry.get("source").and_then(Value::as_str)) + else { + continue; + }; + let (identity, package) = + if source == "npm:pi-mcp-adapter" || source.starts_with("npm:pi-mcp-adapter@") { + let managed = base.join("npm/node_modules/pi-mcp-adapter"); + let package = if managed.exists() || base != agent { + managed + } else { + // Older Pi versions installed user npm packages globally. + [ + home.join(".npm-global/lib/node_modules/pi-mcp-adapter"), + home.join("AppData/Roaming/npm/node_modules/pi-mcp-adapter"), + PathBuf::from("/opt/homebrew/lib/node_modules/pi-mcp-adapter"), + PathBuf::from("/usr/local/lib/node_modules/pi-mcp-adapter"), + ] + .into_iter() + .find(|p| p.join("package.json").is_file()) + .unwrap_or(managed) + }; + ("npm:pi-mcp-adapter".to_owned(), package) + } else if source.starts_with("git:") || source.starts_with("https://") { + let source = source + .trim_start_matches("git:") + .trim_start_matches("https://"); + let source = source + .split('@') + .next() + .unwrap_or(source) + .trim_end_matches(".git"); + if source != "github.com/nicobailon/pi-mcp-adapter" { + continue; + } + (format!("git:{source}"), base.join("git").join(source)) + } else if source.starts_with("npm:") { + continue; + } else { + let package = expand_package_path(source, base, home); + ( + package + .canonicalize() + .unwrap_or_else(|_| package.clone()) + .to_string_lossy() + .into_owned(), + package, + ) + }; + let delta = entry.get("autoload").and_then(Value::as_bool) == Some(false); + let extension = if delta { + packages.get(&identity).cloned().flatten() + } else { + adapter_extension(&package) + } + .filter(|path| { + entry + .get("extensions") + .and_then(Value::as_array) + .is_none_or(|filters| { + let base = path.parent().unwrap_or(&package); + if delta { + ordered_overrides(path, base, filters, true).unwrap_or(true) + } else { + !filters.is_empty() + && resource_enabled(path, base, filters, true).unwrap_or(true) + } + }) + }); + // Project declarations replace the user entry, including a disabled + // or not-yet-installed project package. + packages.insert(identity, extension); + } + for source in settings + .get("extensions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + if source.starts_with('-') || source.starts_with('!') { + continue; + } + let path = expand_package_path(source.trim_start_matches('+'), base, home); + extensions.extend(adapter_extension(&path)); + } + if let Ok(entries) = fs::read_dir(base.join("extensions")) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + extensions.extend(adapter_extension(&entry.path())); + } + } + } + extensions.extend(packages.into_values().flatten()); + extensions.into_iter().any(|path| { + let decision = |base, settings: &Value, project: bool| { + settings + .get("extensions") + .and_then(Value::as_array) + .and_then(|filters| { + if project { + ordered_overrides(&path, base, filters, false) + } else { + resource_enabled(&path, base, filters, false) + } + }) + }; + project_dir + .as_deref() + .and_then(|base| decision(base, &project_settings, true)) + .or_else(|| decision(agent, settings, false)) + .unwrap_or(true) + }) +} + +/// Pi resource filters: glob includes/excludes, then exact force-includes and +/// force-excludes. Top-level extension settings use only the override forms. +fn resource_enabled(path: &Path, base: &Path, filters: &[Value], includes: bool) -> Option { + let matches = |pattern, exact| resource_matches(path, base, pattern, exact); + let filters: Vec<_> = filters.iter().filter_map(Value::as_str).collect(); + let plain: Vec<_> = filters + .iter() + .filter(|f| !f.starts_with(['!', '+', '-'])) + .collect(); + let mut enabled = if includes && !plain.is_empty() { + Some(plain.iter().any(|f| matches(f, false))) + } else { + None + }; + for (prefix, value, exact) in [('!', false, false), ('+', true, true), ('-', false, true)] { + if filters + .iter() + .filter_map(|f| f.strip_prefix(prefix)) + .any(|f| matches(f, exact)) + { + enabled = Some(value); + } + } + enabled +} + +fn resource_matches(path: &Path, base: &Path, pattern: &str, exact: bool) -> bool { + let relative = path + .strip_prefix(base) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let absolute = path.to_string_lossy().replace('\\', "/"); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let pattern = pattern.replace('\\', "/"); + let pattern = pattern.strip_prefix("./").unwrap_or(&pattern); + if exact { + pattern == relative || pattern == absolute + } else { + glob::Pattern::new(pattern) + .is_ok_and(|p| p.matches(&relative) || p.matches(&name) || p.matches(&absolute)) + } +} + +/// Project overrides are applied in order; the last matching entry wins. +fn ordered_overrides(path: &Path, base: &Path, filters: &[Value], includes: bool) -> Option { + filters + .iter() + .filter_map(Value::as_str) + .filter_map(|filter| { + let (pattern, enabled, exact) = match filter.chars().next() { + Some('!') => (&filter[1..], false, false), + Some('-') => (&filter[1..], false, true), + Some('+') => (&filter[1..], true, true), + _ if includes => (filter, true, false), + _ => return None, + }; + resource_matches(path, base, pattern, exact).then_some(enabled) + }) + .next_back() +} + +fn expand_package_path(source: &str, base: &Path, home: &Path) -> PathBuf { + if let Some(relative) = source.strip_prefix("~/") { + home.join(relative) + } else { + base.join(source) + } +} + +fn adapter_extension(path: &Path) -> Option { + let package = if path.is_file() { + if path.file_name()? != "index.ts" { + return None; + } + path.parent()? + } else { + path + }; + let entry = package.join("index.ts"); + (entry.is_file() + && read_json(&package.join("package.json"))? + .get("name")? + .as_str()? + == "pi-mcp-adapter") + .then_some(entry) +} + +fn adapter_mcp_config_paths_with( + homes: &[PathBuf], + cwd: Option<&Path>, + exclusive: bool, + agent_dir: impl Fn(&Path) -> PathBuf, +) -> Vec { + let mut paths = BTreeSet::new(); + for home in homes { + let agent_mcp = agent_dir(home).join("mcp.json"); + if exclusive { + paths.insert(agent_mcp); + continue; + } + paths.insert(home.join(".config/mcp/mcp.json")); + paths.insert(home.join(".agents/mcp.json")); + paths.insert(home.join(".agents/mcp/mcp.json")); + paths.insert(agent_mcp); + } + if exclusive { + return paths.into_iter().collect(); + } + if let Some(cwd) = cwd { + let cwd = canonicalize_dir(cwd).unwrap_or_else(|| cwd.to_path_buf()); + paths.insert(cwd.join(".mcp.json")); + paths.insert(cwd.join(".pi/mcp.json")); + for home in homes { + let Some(root) = configured_ancestor_root(home, &cwd, &agent_dir) else { + continue; + }; + for directory in ancestor_project_dirs(&cwd, &root) { + paths.insert(directory.join(".mcp.json")); + paths.insert(directory.join(".pi/mcp.json")); + } + } + } + paths.into_iter().collect() +} + +fn pi_mcp_exclusive_mode() -> bool { + env::var("PI_MCP_CONFIG_MODE").is_ok_and(|value| value.trim().eq_ignore_ascii_case("exclusive")) +} + +/// Last user-global `settings.ancestorConfigRoots` that pi-mcp-adapter would honor. +/// +/// Later global files replace earlier ones. An empty or invalid value disables +/// ancestor loading. Project files cannot enable it. +fn configured_ancestor_root( + home: &Path, + cwd: &Path, + agent_dir: &impl Fn(&Path) -> PathBuf, +) -> Option { + let mut configured = None; + for path in global_mcp_sources(home, agent_dir) { + match read_ancestor_setting(&path) { + AncestorSetting::Unset => {} + AncestorSetting::Clear => configured = Some(Vec::new()), + AncestorSetting::Roots(roots) => configured = Some(roots), + } + } + let roots = configured.filter(|roots| !roots.is_empty())?; + let home_id = canonicalize_dir(home)?; + let mut valid = Vec::new(); + for entry in roots { + let Some(expanded) = expand_ancestor_root(&entry, home) else { + continue; + }; + let Some(root) = canonicalize_dir(&expanded) else { + continue; + }; + // The deepest existing directory under the user's home that contains + // the working directory wins. A root equal to the working directory + // adds no parent files. + if root.starts_with(&home_id) && root_contains_cwd(&root, cwd) { + valid.push(root); + } + } + valid.sort_by_key(|path| std::cmp::Reverse(path.as_os_str().len())); + valid.into_iter().next() +} + +fn global_mcp_sources(home: &Path, agent_dir: &impl Fn(&Path) -> PathBuf) -> Vec { + let user = agent_dir(home).join("mcp.json"); + let generic = home.join(".config/mcp/mcp.json"); + let mut sources = Vec::new(); + if generic != user { + sources.push(generic.clone()); + } + for relative in [ + Path::new(".agents/mcp.json"), + Path::new(".agents/mcp/mcp.json"), + ] { + let path = home.join(relative); + if path != user && path != generic { + sources.push(path); + } + } + sources.push(user); + sources +} + +enum AncestorSetting { + Unset, + Clear, + Roots(Vec), +} + +fn read_ancestor_setting(path: &Path) -> AncestorSetting { + let Ok(bytes) = fs::read(path) else { + return AncestorSetting::Unset; + }; + let Ok(text) = str::from_utf8(&bytes) else { + return AncestorSetting::Unset; + }; + let Ok(value) = json5::from_str::(text.trim_start_matches('\u{feff}')) else { + return AncestorSetting::Unset; + }; + let Some(roots) = value.pointer("/settings/ancestorConfigRoots") else { + return AncestorSetting::Unset; + }; + let Some(entries) = roots.as_array() else { + return AncestorSetting::Clear; + }; + if entries.is_empty() { + return AncestorSetting::Clear; + } + AncestorSetting::Roots( + entries + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ) +} + +fn expand_ancestor_root(entry: &str, home: &Path) -> Option { + entry + .strip_prefix("~/") + .map(|rest| home.join(rest)) + .or_else(|| { + let path = PathBuf::from(entry); + path.is_absolute().then_some(path) + }) +} + +fn ancestor_project_dirs(cwd: &Path, root: &Path) -> Vec { + let Some(cwd) = canonicalize_dir(cwd) else { + return Vec::new(); + }; + let Some(root) = canonicalize_dir(root) else { + return Vec::new(); + }; + let mut dirs = Vec::new(); + let Some(mut current) = cwd.parent().map(Path::to_path_buf) else { + return dirs; + }; + while current.starts_with(&root) { + dirs.insert(0, current.clone()); + if current == root { + break; + } + let Some(parent) = current.parent().map(Path::to_path_buf) else { + break; + }; + if parent == current { + break; + } + current = parent; + } + dirs +} + +fn root_contains_cwd(root: &Path, cwd: &Path) -> bool { + let Some(cwd) = canonicalize_dir(cwd) else { + return false; + }; + let Some(root) = canonicalize_dir(root) else { + return false; + }; + cwd.starts_with(root) +} + +fn canonicalize_dir(path: &Path) -> Option { + let canonical = fs::canonicalize(path).ok()?; + canonical.is_dir().then_some(canonical) +} + +/// Skill directories Pi loads. +/// +/// User skills come from the Pi agent directory and `~/.agents/skills`. +/// Project `.pi/skills` is only the working directory. Project `.agents/skills` +/// walks from the working directory through the git repository root, or the +/// filesystem root when the working directory is not in a repository. +fn skill_roots() -> Vec { + let current = env::current_dir().ok(); + skill_roots_in(&metadata::user_home_dirs(), current.as_deref()) +} + +fn skill_roots_in(homes: &[PathBuf], cwd: Option<&Path>) -> Vec { + let mut roots = BTreeSet::new(); + for home in homes { + roots.insert(user_pi_agent_dir(home).join("skills")); + roots.insert(home.join(".agents/skills")); + } + if let Some(cwd) = cwd { + let cwd = canonicalize_dir(cwd).unwrap_or_else(|| cwd.to_path_buf()); + roots.insert(cwd.join(".pi/skills")); + roots.extend(agents_skill_dirs(&cwd)); + } + roots.into_iter().collect() +} + +fn agents_skill_dirs(start: &Path) -> Vec { + let start = canonicalize_dir(start).unwrap_or_else(|| start.to_path_buf()); + let git_root = git_repo_root(&start); + let mut dir = start; + let mut dirs = Vec::new(); + loop { + dirs.push(dir.join(".agents/skills")); + if git_root.as_ref().is_some_and(|root| &dir == root) { + break; + } + let Some(parent) = dir.parent().map(Path::to_path_buf) else { + break; + }; + if parent == dir { + break; + } + dir = parent; + } + dirs +} + +fn git_repo_root(start: &Path) -> Option { + let mut dir = start.to_path_buf(); + loop { + if dir.join(".git").exists() { + return Some(dir); + } + let parent = dir.parent()?.to_path_buf(); + if parent == dir { + return None; + } + dir = parent; + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + }; + + use serde_json::json; + + use super::{ + adapter_mcp_config_paths_with as mcp_config_paths_with, is_pi, mcp_config_paths, + package_version, skill_roots, skill_roots_in, + }; + use crate::provider::pi::user_pi_agent_dir; + use crate::provider::vscode::discovery::mcp_servers_from_value; + + #[test] + fn builtin_mcp_uses_only_native_paths_and_project_trust() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = home.join("repo/child"); + let agent = home.join(".pi/agent"); + fs::create_dir_all(project.join(".pi")).unwrap(); + fs::create_dir_all(&agent).unwrap(); + let project = fs::canonicalize(project).unwrap(); + let paths = || { + super::mcp_config_paths_with( + std::slice::from_ref(&home), + Some(&project), + false, + |home| home.join(".pi/agent"), + ) + }; + assert_eq!(paths(), vec![agent.join("mcp.json")]); + fs::write( + agent.join("trust.json"), + serde_json::to_vec(&json!({ + project.parent().unwrap().to_str().unwrap(): true + })) + .unwrap(), + ) + .unwrap(); + let trusted = paths(); + assert_eq!(trusted.len(), 2); + assert!(trusted.contains(&project.join(".pi/mcp.json"))); + fs::write( + agent.join("trust.json"), + serde_json::to_vec(&json!({ + project.parent().unwrap().to_str().unwrap(): true, + project.to_str().unwrap(): false + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(paths(), vec![agent.join("mcp.json")]); + fs::remove_file(agent.join("trust.json")).unwrap(); + fs::write( + agent.join("settings.json"), + r#"{"defaultProjectTrust":"always"}"#, + ) + .unwrap(); + assert_eq!(paths().len(), 2); + } + + #[test] + fn adapter_mcp_requires_an_enabled_installed_package() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = home.join("repo"); + let agent = home.join(".pi/agent"); + let package = agent.join("npm/node_modules/pi-mcp-adapter"); + fs::create_dir_all(&package).unwrap(); + fs::create_dir_all(&project).unwrap(); + let paths = || { + super::mcp_config_paths_with( + std::slice::from_ref(&home), + Some(&project), + false, + |home| home.join(".pi/agent"), + ) + }; + fs::write( + agent.join("settings.json"), + r#"{"packages":["npm:pi-mcp-adapter@2.34.0"]}"#, + ) + .unwrap(); + assert_eq!( + paths(), + vec![agent.join("mcp.json")], + "declaration alone is not an install" + ); + fs::write( + package.join("package.json"), + r#"{"name":"pi-mcp-adapter","version":"2.34.0","pi":{"extensions":["./index.ts"]}}"#, + ) + .unwrap(); + fs::write(package.join("index.ts"), "").unwrap(); + assert!(paths().contains(&home.join(".agents/mcp.json"))); + assert!(paths().iter().any(|p| p.ends_with(".mcp.json"))); + fs::write( + agent.join("settings.json"), + r#"{"packages":[{"source":"npm:pi-mcp-adapter","extensions":[]}]}"#, + ) + .unwrap(); + assert_eq!( + paths(), + vec![agent.join("mcp.json")], + "disabled extensions do not replace builtin MCP" + ); + } + + #[test] + fn adapter_filters_and_project_overrides_control_mcp_sources() { + let root = tempfile::tempdir().unwrap(); + let home = root.path(); + let agent = home.join(".pi/agent"); + let project = home.join("project"); + let package = agent.join("npm/node_modules/pi-mcp-adapter"); + fs::create_dir_all(&package).unwrap(); + fs::create_dir_all(project.join(".pi")).unwrap(); + fs::write(package.join("package.json"), r#"{"name":"pi-mcp-adapter"}"#).unwrap(); + fs::write(package.join("index.ts"), "").unwrap(); + let paths = || { + super::mcp_config_paths_with(&[home.to_path_buf()], Some(&project), false, |home| { + home.join(".pi/agent") + }) + }; + for (filters, enabled) in [ + (json!(["*.ts"]), true), + (json!(["*.ts", "!index.ts"]), false), + (json!(["!**", "+./index.ts"]), true), + (json!(["*.ts", "-./index.ts"]), false), + ] { + fs::write( + agent.join("settings.json"), + json!({"packages":[{"source":"npm:pi-mcp-adapter", "extensions":filters}]}) + .to_string(), + ) + .unwrap(); + assert_eq!(paths().contains(&home.join(".agents/mcp.json")), enabled); + } + fs::write( + agent.join("settings.json"), + r#"{"packages":["npm:pi-mcp-adapter"],"defaultProjectTrust":"always"}"#, + ) + .unwrap(); + fs::write( + project.join(".pi/settings.json"), + r#"{"packages":[{"source":"npm:pi-mcp-adapter","extensions":[]}]}"#, + ) + .unwrap(); + assert!(!paths().contains(&home.join(".agents/mcp.json"))); + fs::write( + project.join(".pi/settings.json"), + r#"{"packages":[{"source":"npm:pi-mcp-adapter","autoload":false,"extensions":[]}]}"#, + ) + .unwrap(); + assert!( + paths().contains(&home.join(".agents/mcp.json")), + "empty project delta preserves the user adapter" + ); + // A project declaration cannot disable the user's adapter until trusted. + fs::write( + agent.join("settings.json"), + r#"{"packages":["npm:pi-mcp-adapter"]}"#, + ) + .unwrap(); + assert!(paths().contains(&home.join(".agents/mcp.json"))); + fs::write(agent.join("settings.json"), r#"{"packages":["npm:pi-mcp-adapter"],"extensions":["-npm/node_modules/pi-mcp-adapter/index.ts"]}"#).unwrap(); + assert!(!paths().contains(&home.join(".agents/mcp.json"))); + // Native filters use the same precedence: force-exclude wins within + // a scope; a matching project override takes precedence over the user. + for filters in [ + json!(["-builtin:mcp"]), + json!(["!builtin:*"]), + json!(["-builtin:mcp", "+builtin:mcp"]), + ] { + fs::write( + agent.join("settings.json"), + json!({"extensions": filters}).to_string(), + ) + .unwrap(); + assert!(paths().is_empty()); + } + fs::write( + agent.join("settings.json"), + r#"{"extensions":["-builtin:mcp"],"defaultProjectTrust":"always"}"#, + ) + .unwrap(); + fs::write( + project.join(".pi/settings.json"), + r#"{"extensions":["+builtin:mcp"]}"#, + ) + .unwrap(); + assert_eq!(paths().len(), 2); + fs::write( + project.join(".pi/settings.json"), + r#"{"extensions":["-builtin:mcp","+builtin:mcp"]}"#, + ) + .unwrap(); + assert_eq!(paths().len(), 2); + fs::write(agent.join("settings.json"), "{}").unwrap(); + let hidden = agent.join("extensions/.disabled-pi-mcp-adapter"); + fs::create_dir_all(&hidden).unwrap(); + fs::write(hidden.join("package.json"), r#"{"name":"pi-mcp-adapter"}"#).unwrap(); + fs::write(hidden.join("index.ts"), "").unwrap(); + assert!(!paths().contains(&home.join(".agents/mcp.json"))); + } + + #[test] + fn accepts_earendil_pi_package_and_reads_version() { + let root = temporary("pi-package"); + let package = root.join("node_modules/@earendil-works/pi-coding-agent"); + let bundle = package.join("dist/bundle"); + fs::create_dir_all(&bundle).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@earendil-works/pi-coding-agent","version":"0.85.1"}"#, + ) + .unwrap(); + let executable = bundle.join("cli.js"); + fs::write(&executable, "#!/usr/bin/env node\n").unwrap(); + + assert!(is_pi(&executable)); + assert_eq!(package_version(&executable).as_deref(), Some("0.85.1")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_an_unrelated_pi_binary() { + let root = temporary("pi-unrelated"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("package.json"), + r#"{"name":"raspberry-pi-tools","version":"1.0.0"}"#, + ) + .unwrap(); + let executable = root.join("pi"); + fs::write(&executable, "#!/bin/sh\n").unwrap(); + + assert!(!is_pi(&executable)); + assert_eq!(package_version(&executable), None); + + let _ = fs::remove_dir_all(root); + } + + // npm-generated Windows global shims: package.json is beside these launchers + // under node_modules, rather than in an ancestor of the launcher itself. + const NPM_CMD_SHIM: &str = r#"@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js" %* +"#; + + const NPM_POWERSHELL_SHIM: &str = r#"#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } else { + & "node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret +"#; + + const NPM_SHELL_SHIM: &str = r#"#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" "$@" +else + exec node "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" "$@" +fi +"#; + + fn npm_shim_fixture(name: &str, source: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let shim = write_npm_shim(root.path(), name, source); + (root, shim) + } + + fn write_npm_shim(prefix: &Path, name: &str, source: &str) -> PathBuf { + let package = prefix.join("node_modules/@earendil-works/pi-coding-agent"); + fs::create_dir_all(package.join("dist/bundle")).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@earendil-works/pi-coding-agent","version":"0.85.1"}"#, + ) + .unwrap(); + fs::write(package.join("dist/bundle/cli.js"), "#!/usr/bin/env node\n").unwrap(); + let shim = prefix.join(name); + fs::write(&shim, source).unwrap(); + shim + } + + #[test] + fn discovers_powershell_only_npm_installs_without_pathext() { + const EXPECTED_SHIM: &str = "AGENTDESKTOP_PI_PS1_TEST_SHIM"; + if let Some(expected) = std::env::var_os(EXPECTED_SHIM) { + #[cfg(windows)] + let executable = super::discover() + .expect("Pi should be discovered") + .executable; + // Exercise Windows candidate discovery on other platforms too. + #[cfg(not(windows))] + let executable = super::windows_executable_candidates(Default::default()) + .into_iter() + .filter(|path| path.is_file()) + .find(|path| is_pi(path)) + .expect("PowerShell-only Pi should be discovered"); + assert_eq!(executable, PathBuf::from(expected)); + return; + } + + for use_path in [false, true] { + let home = tempfile::tempdir().unwrap(); + let prefix = home.path().join(if use_path { + "z-custom-npm-prefix" + } else { + "AppData/Roaming/npm" + }); + let shim = write_npm_shim(&prefix, "pi.ps1", NPM_POWERSHELL_SHIM); + let path = if use_path { + // The first PATH match wins over later entries and fallback locations. + let later_prefix = home.path().join("a-older-npm-prefix"); + write_npm_shim(&later_prefix, "pi.ps1", NPM_POWERSHELL_SHIM); + write_npm_shim( + &home.path().join("AppData/Roaming/npm"), + "pi.ps1", + NPM_POWERSHELL_SHIM, + ); + std::env::join_paths([&prefix, &later_prefix]).unwrap() + } else { + Default::default() + }; + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider::pi::discovery::tests::discovers_powershell_only_npm_installs_without_pathext", + "--nocapture", + ]) + .env(EXPECTED_SHIM, &shim) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("PATH", path) + .env("PATHEXT", ".COM;.EXE;.BAT;.CMD") + .output() + .unwrap(); + assert!( + output.status.success(), + "Pi with only pi.ps1 (on PATH: {use_path}):\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + #[test] + fn accepts_npm_cmd_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi.cmd", NPM_CMD_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn accepts_npm_powershell_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi.ps1", NPM_POWERSHELL_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn accepts_npm_shell_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi", NPM_SHELL_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn rejects_unrelated_launchers_beside_pi_package() { + for (name, source) in [ + ("pi.cmd", NPM_CMD_SHIM), + ("pi.ps1", NPM_POWERSHELL_SHIM), + ("pi", NPM_SHELL_SHIM), + ] { + let unrelated = source + .replace("@earendil-works/pi-coding-agent", "unrelated-pi") + .replace(r"@earendil-works\pi-coding-agent", "unrelated-pi"); + let (_root, shim) = npm_shim_fixture(name, &unrelated); + assert!(!is_pi(&shim)); + assert_eq!(package_version(&shim), None); + } + } + + #[test] + fn rejects_npm_shim_without_matching_package_metadata() { + let (root, shim) = npm_shim_fixture("pi.cmd", NPM_CMD_SHIM); + let manifest = root + .path() + .join("node_modules/@earendil-works/pi-coding-agent/package.json"); + for contents in [ + r#"{"name":"unrelated-pi","version":"0.85.1"}"#, + r#"{"name":"@earendil-works/pi-coding-agent"}"#, + ] { + fs::write(&manifest, contents).unwrap(); + assert!(!is_pi(&shim)); + assert_eq!(package_version(&shim), None); + } + } + + #[test] + fn rejects_commented_npm_shim_references() { + let source = format!( + "{}\nexec unrelated-pi\n", + NPM_SHELL_SHIM + .lines() + .map(|line| format!("# {line}\n")) + .collect::() + ); + let (_root, shim) = npm_shim_fixture("pi", &source); + assert!(!is_pi(&shim)); + } + + #[test] + fn rejects_oversized_npm_shims() { + let source = format!("{NPM_CMD_SHIM}{}", " ".repeat(16 * 1024)); + let (_root, shim) = npm_shim_fixture("pi.cmd", &source); + assert!(!is_pi(&shim)); + } + + #[test] + fn reads_adapter_servers_without_secrets_or_arguments() { + let servers = mcp_servers_from_value( + &json!({ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp", + "headers": { "Authorization": "secret" } + }, + "chrome": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@1.6.0"], + "env": { "TOKEN": "secret" } + }, + "skipped": { + "command": "npx", + "disabled": true + } + } + }), + Path::new("/home/tester/.pi/agent/mcp.json"), + ); + + assert_eq!(servers.len(), 3); + assert_eq!(servers[0].name, "chrome"); + assert_eq!(servers[0].transport, "stdio"); + assert_eq!(servers[0].command.as_deref(), Some("npx")); + assert!(servers[0].enabled); + assert_eq!(servers[1].name, "github"); + assert_eq!(servers[1].transport, "http"); + assert_eq!( + servers[1].url.as_deref(), + Some("https://api.githubcopilot.com/mcp") + ); + assert!(!servers[2].enabled); + assert!( + servers + .iter() + .all(|server| server.source.ends_with("mcp.json")) + ); + } + + #[test] + fn project_mcp_stays_in_the_working_directory_unless_opted_in() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let repo = home.join("repo"); + let project = repo.join("child"); + let outside = home.join("outside"); + fs::create_dir_all(project.join(".pi")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::create_dir_all(repo.join(".pi")).unwrap(); + let project_id = fs::canonicalize(&project).unwrap(); + let repo_id = fs::canonicalize(&repo).unwrap(); + let outside_id = fs::canonicalize(&outside).unwrap(); + let home_id = fs::canonicalize(&home).unwrap(); + let agent = |home: &Path| home.join(".pi/agent"); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(paths.contains(&project_id.join(".mcp.json"))); + assert!(paths.contains(&project_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + assert!(!paths.contains(&repo_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&outside_id.join(".mcp.json"))); + assert!(paths.contains(&home.join(".config/mcp/mcp.json"))); + assert!(paths.contains(&home.join(".agents/mcp.json"))); + assert!(paths.contains(&home.join(".agents/mcp/mcp.json"))); + assert!(paths.contains(&agent(&home).join("mcp.json"))); + + // A project file cannot widen discovery. + fs::write( + project.join(".mcp.json"), + r#"{"settings":{"ancestorConfigRoots":["~/"]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + + fs::create_dir_all(home.join(".config/mcp")).unwrap(); + fs::write( + home.join(".config/mcp/mcp.json"), + r#"{"settings":{"ancestorConfigRoots":["~/repo"]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(paths.contains(&repo_id.join(".mcp.json"))); + assert!(paths.contains(&repo_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&outside_id.join(".mcp.json"))); + assert!(!paths.contains(&home_id.join(".mcp.json"))); + + // The Pi agent file is the last global source, so it can turn the opt-in off. + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + fs::write( + agent(&home).join("mcp.json"), + r#"{"settings":{"ancestorConfigRoots":[]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + + let paths = mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), true, agent); + assert_eq!(paths, vec![agent(&home).join("mcp.json")]); + } + + #[test] + fn ancestor_mcp_ignores_roots_outside_the_home_or_working_directory() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = home.join("work/child"); + let elsewhere = home.join("elsewhere"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + fs::write( + home.join(".pi/agent/mcp.json"), + format!( + r#"{{"settings":{{"ancestorConfigRoots":["{}", "~/elsewhere"]}}}}"#, + root.path().display() + ), + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, |home| { + home.join(".pi/agent") + }); + let root_id = fs::canonicalize(root.path()).unwrap(); + let elsewhere_id = fs::canonicalize(&elsewhere).unwrap(); + let work_id = fs::canonicalize(home.join("work")).unwrap(); + assert!(!paths.contains(&root_id.join(".mcp.json"))); + assert!(!paths.contains(&elsewhere_id.join(".mcp.json"))); + assert!(!paths.contains(&work_id.join(".mcp.json"))); + } + + #[test] + fn skill_roots_follow_pi_project_scopes() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let repo = root.path().join("repo"); + let project = repo.join("child"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + fs::write(repo.join(".git"), "gitdir: /unused").unwrap(); + + let project = fs::canonicalize(&project).unwrap(); + let repo = fs::canonicalize(&repo).unwrap(); + let paths = skill_roots_in(std::slice::from_ref(&home), Some(&project)); + assert!(paths.contains(&project.join(".pi/skills"))); + assert!(paths.contains(&project.join(".agents/skills"))); + assert!(paths.contains(&repo.join(".agents/skills"))); + assert!(!paths.contains(&repo.join(".pi/skills"))); + assert!( + !paths.contains( + &fs::canonicalize(root.path()) + .unwrap() + .join(".agents/skills") + ) + ); + assert!(!paths.contains(&fs::canonicalize(root.path()).unwrap().join(".pi/skills"))); + assert!(paths.contains(&home.join(".agents/skills"))); + assert!(paths.contains(&user_pi_agent_dir(&home).join("skills"))); + + let loose = root.path().join("loose/child"); + fs::create_dir_all(&loose).unwrap(); + let loose = fs::canonicalize(&loose).unwrap(); + let paths = skill_roots_in(&[home], Some(&loose)); + assert!(paths.contains(&loose.join(".pi/skills"))); + assert!(!paths.contains(&loose.parent().unwrap().join(".pi/skills"))); + assert!(paths.contains(&loose.parent().unwrap().join(".agents/skills"))); + assert!( + paths.contains( + &loose + .parent() + .unwrap() + .parent() + .unwrap() + .join(".agents/skills") + ) + ); + } + + #[test] + fn pi_agent_dir_overrides_match_all_consumers() { + const CHILD_HOME: &str = "AGENTDESKTOP_PI_PATH_TEST_HOME"; + const CHILD_EXPECTED: &str = "AGENTDESKTOP_PI_PATH_TEST_EXPECTED"; + if let Some(home) = std::env::var_os(CHILD_HOME) { + let home = PathBuf::from(home); + let expected = PathBuf::from(std::env::var_os(CHILD_EXPECTED).unwrap()); + assert_eq!(user_pi_agent_dir(&home), expected); + assert!(mcp_config_paths().contains(&expected.join("mcp.json"))); + assert!(skill_roots().contains(&expected.join("skills"))); + return; + } + + // Isolate environment overrides in a subprocess so parallel tests never + // observe changes to HOME, USERPROFILE, or PI_CODING_AGENT_DIR. + let home = tempfile::tempdir().unwrap(); + let absolute = home.path().join("absolute-agent"); + let cases = [ + ( + Some(Path::new("~/.pi/custom-agent")), + home.path().join(".pi/custom-agent"), + ), + (Some(Path::new("~")), home.path().to_owned()), + ( + Some(Path::new("relative-agent")), + PathBuf::from("relative-agent"), + ), + (Some(absolute.as_path()), absolute.clone()), + (Some(Path::new("")), home.path().join(".pi/agent")), + (None, home.path().join(".pi/agent")), + ( + Some(Path::new("~another-user/agent")), + PathBuf::from("~another-user/agent"), + ), + #[cfg(windows)] + ( + Some(Path::new(r"~\.pi\custom-agent")), + home.path().join(".pi/custom-agent"), + ), + #[cfg(unix)] + ( + Some(Path::new(r"~\.pi\custom-agent")), + PathBuf::from(r"~\.pi\custom-agent"), + ), + ]; + for (configured, expected) in cases { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "provider::pi::discovery::tests::pi_agent_dir_overrides_match_all_consumers", + "--nocapture", + ]) + .env(CHILD_HOME, home.path()) + .env(CHILD_EXPECTED, expected) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()); + if let Some(configured) = configured { + command.env("PI_CODING_AGENT_DIR", configured); + } else { + command.env_remove("PI_CODING_AGENT_DIR"); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "PI_CODING_AGENT_DIR={configured:?}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + fn temporary(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("agentdesktop-{name}-{}", std::process::id())) + } +} diff --git a/crates/agent/src/provider/pi/mod.rs b/crates/agent/src/provider/pi/mod.rs new file mode 100644 index 00000000..ff031466 --- /dev/null +++ b/crates/agent/src/provider/pi/mod.rs @@ -0,0 +1,156 @@ +use std::path::PathBuf; + +use agentdesktop_core::config::DaemonConfig; +use agentdesktop_core::model::{DaemonScope, Discovery}; + +use super::{Provider, ReconcileContext}; +use crate::reconcile::ReconcilePlan; + +pub(super) mod discovery; +mod reconcile; + +#[derive(Clone, Default)] +pub struct Pi { + /// User paths only; system daemons have no Pi files to manage. + pub models_path: Option, + pub settings_path: Option, +} + +impl Pi { + pub const ID: &'static str = "pi"; + pub const DISPLAY_NAME: &'static str = "Pi"; + pub const PACKAGE_NAME: &'static str = "@earendil-works/pi-coding-agent"; +} + +#[async_trait::async_trait] +impl Provider for Pi { + fn id(&self) -> &'static str { + Self::ID + } + + async fn discover(&self) -> Discovery { + Discovery { + agents: discovery::discover().into_iter().collect(), + model_runtimes: Vec::new(), + } + } + + fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + if let Some(plan) = ctx.skip_unless( + DaemonScope::User, + config.programs.pi.is_some(), + Self::DISPLAY_NAME, + ) { + return Ok(plan); + } + let plan = ReconcilePlan::default(); + let (Some(models_path), Some(settings_path)) = (&self.models_path, &self.settings_path) + else { + anyhow::bail!("Pi user configuration paths are unavailable"); + }; + let configured = config.programs.pi.as_ref().map(|provider| { + let gateway = config + .llm_gateway + .as_ref() + .filter(|_| provider.use_llm_gateway); + (provider, gateway) + }); + reconcile::plan( + models_path, + settings_path, + &ctx.credential_helper, + &ctx.socket, + configured, + &plan, + )?; + Ok(plan) + } +} + +pub fn user_pi_agent_dir(home: &std::path::Path) -> PathBuf { + std::env::var_os("PI_CODING_AGENT_DIR") + .filter(|value| !value.is_empty()) + .map(|value| { + let path = PathBuf::from(value); + // Path components handle ~/ on every platform and ~\ on Windows, + // matching Pi's platform-specific tilde expansion. + match path.strip_prefix("~") { + Ok(relative) => home.join(relative), + Err(_) => path, + } + }) + .unwrap_or_else(|| home.join(".pi/agent")) +} + +#[cfg(test)] +mod tests { + use agentdesktop_core::config::{DaemonConfig, parse_daemon}; + + use super::{Pi, Provider, ReconcileContext}; + + #[test] + fn skips_configured_pi_in_system_mode() { + let root = tempfile::tempdir().unwrap(); + let pi = Pi { + models_path: Some(root.path().join("models.json")), + settings_path: Some(root.path().join("settings.json")), + }; + let ctx = ReconcileContext { + llm_proxy: None, + merge_user_settings: false, + credential_helper: root.path().join("agentdesktop"), + socket: root.path().join("agentdesktop.sock"), + }; + let config = + parse_daemon("programs:\n pi:\n managedConfig:\n providers: {}\n").unwrap(); + + let plan = pi + .plan(&ctx, &config) + .expect("Pi is skipped in system mode"); + assert!(plan.inactive_reason().unwrap().contains("--user")); + plan.apply().unwrap(); + assert!(!pi.models_path.as_ref().unwrap().exists()); + assert!(!pi.settings_path.as_ref().unwrap().exists()); + } + + #[test] + fn system_mode_leaves_user_pi_files_untouched() { + let root = tempfile::tempdir().unwrap(); + let pi = Pi { + models_path: Some(root.path().join("models.json")), + settings_path: Some(root.path().join("settings.json")), + }; + let mut ctx = ReconcileContext { + llm_proxy: None, + merge_user_settings: true, + credential_helper: root.path().join("agentdesktop"), + socket: root.path().join("agentdesktop.sock"), + }; + let config = + parse_daemon("programs:\n pi:\n managedConfig:\n providers: {}\n").unwrap(); + pi.plan(&ctx, &config).unwrap().apply().unwrap(); + assert!(pi.models_path.as_ref().unwrap().exists()); + + ctx.merge_user_settings = false; + pi.plan(&ctx, &DaemonConfig::default()) + .unwrap() + .apply() + .unwrap(); + assert!(pi.models_path.as_ref().unwrap().exists()); + } + + #[test] + fn system_mode_needs_no_pi_paths() { + let ctx = ReconcileContext { + llm_proxy: None, + merge_user_settings: false, + credential_helper: "agentdesktop".into(), + socket: "agentdesktop.sock".into(), + }; + Pi::default() + .plan(&ctx, &DaemonConfig::default()) + .unwrap() + .apply() + .unwrap(); + } +} diff --git a/crates/agent/src/provider/pi/reconcile.rs b/crates/agent/src/provider/pi/reconcile.rs new file mode 100644 index 00000000..d04ee275 --- /dev/null +++ b/crates/agent/src/provider/pi/reconcile.rs @@ -0,0 +1,776 @@ +use super::Pi; + +use std::path::Path; + +use agentdesktop_core::config::{LlmGatewayAuthentication, LlmGatewayConfig, PiConfig}; +use anyhow::Context; +use serde_json::{Map, Value, json}; + +use crate::provider::{ + json_merge, + shared::{CommandSpec, deep_merge, render_command, responses_base_url}, +}; +use crate::reconcile::ReconcilePlan; + +const PROVIDER_NAME: &str = "agentdesktop"; +const DEFAULT_API: &str = "anthropic-messages"; +/// `models.json` may hold the user's own provider API keys: a file we write +/// is owner-only, and the dry-run report shows the action and path only. +const MODELS_OPTIONS: json_merge::MergeOptions = json_merge::MergeOptions { + mode: 0o600, + keyed_arrays: &[], + redact_diff: true, + // Pi accepts comments and trailing commas in models.json. + format: json_merge::JsonFormat::Jsonc, + replace_paths: &[], +}; + +pub(super) fn plan( + models_path: &Path, + settings_path: &Path, + credential_helper: &Path, + socket: &Path, + config: Option<(&PiConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let models_state = json_merge::state_path(models_path); + let settings_state = json_merge::state_path(settings_path); + let Some((config, gateway)) = config else { + json_merge::plan_remove_with( + models_path, + &models_state, + "models", + Pi::DISPLAY_NAME, + MODELS_OPTIONS, + plan, + )?; + json_merge::plan_remove( + settings_path, + &settings_state, + "settings", + Pi::DISPLAY_NAME, + plan, + )?; + return Ok(()); + }; + + json_merge::plan_merge_with( + models_path, + &models_state, + managed_models(config, gateway, credential_helper, socket)?, + false, + "models", + Pi::DISPLAY_NAME, + json_merge::MergeOptions { + replace_paths: if gateway.is_some() { + &["/providers/agentdesktop/models"] + } else { + &[] + }, + ..MODELS_OPTIONS + }, + plan, + )?; + json_merge::plan_merge( + settings_path, + &settings_state, + managed_settings(config, gateway)?, + false, + "settings", + Pi::DISPLAY_NAME, + plan, + )?; + Ok(()) +} + +fn managed_models( + config: &PiConfig, + gateway: Option<&LlmGatewayConfig>, + credential_helper: &Path, + socket: &Path, +) -> anyhow::Result { + let mut settings = serde_json::to_value(&config.managed_config) + .context("serialize Pi pass-through managed configuration")?; + let Some(gateway) = gateway else { + return Ok(settings); + }; + + let default_model = config + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi gateway configuration has no model")?; + let api = config + .api + .as_deref() + .map(str::trim) + .filter(|api| !api.is_empty()) + .unwrap_or(DEFAULT_API); + let uses_credential_helper = gateway + .authentication + .as_ref() + .is_some_and(LlmGatewayAuthentication::uses_credential_helper); + let api_key = if uses_credential_helper { + format!( + "!{}", + render_command(&CommandSpec::new( + credential_helper, + [ + "--socket", + &socket.to_string_lossy(), + "credential", + "--client-id", + Pi::ID, + ], + )) + ) + } else { + "agentdesktop-managed".to_owned() + }; + + let mut models = Vec::new(); + for (id, mut entry) in catalog_entries(config, default_model) { + entry.insert("id".to_owned(), json!(id)); + let model_api = entry.get("api").and_then(Value::as_str).unwrap_or(api); + let base_url = gateway_base_url(gateway, model_api); + entry.insert("baseUrl".to_owned(), json!(base_url)); + models.push(Value::Object(entry)); + } + + let generated = json!({ + "providers": { + (PROVIDER_NAME): { + "baseUrl": gateway_base_url(gateway, api), + "api": api, + "apiKey": api_key, + "authHeader": true, + "models": models, + } + } + }); + deep_merge(&mut settings, generated); + Ok(settings) +} + +fn managed_settings( + config: &PiConfig, + gateway: Option<&LlmGatewayConfig>, +) -> anyhow::Result { + if gateway.is_none() { + return Ok(json!({})); + } + let model = config + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi gateway configuration has no model")?; + Ok(json!({ + "defaultProvider": PROVIDER_NAME, + "defaultModel": model, + })) +} + +fn catalog_entries(config: &PiConfig, default_model: &str) -> Vec<(String, Map)> { + if config.models.is_empty() { + return vec![(default_model.to_owned(), Map::new())]; + } + config + .models + .iter() + .map(|(id, value)| { + let object = match value { + Value::Object(object) => object.clone(), + _ => Map::new(), + }; + (id.clone(), object) + }) + .collect() +} + +fn gateway_base_url(gateway: &LlmGatewayConfig, api: &str) -> String { + match api { + "openai-completions" | "openai-responses" => responses_base_url(gateway), + _ => gateway.url.as_str().trim_end_matches('/').to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use agentdesktop_core::config::parse_daemon; + use serde_json::{Value, json}; + + use super::{managed_models, managed_settings, plan}; + use crate::reconcile::ReconcilePlan; + + fn reconcile_fixture(models: &Path, config: Option<&str>) -> ReconcilePlan { + let config = config.map(|yaml| parse_daemon(yaml).unwrap()); + let changes = ReconcilePlan::default(); + plan( + models, + &models.with_file_name("settings.json"), + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + config.as_ref().map(|config| { + ( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + ) + }), + &changes, + ) + .unwrap(); + changes + } + + #[cfg(unix)] + #[test] + fn models_create_and_merge_use_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + for existing_mode in [None, Some(0o600), Some(0o644)] { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + if let Some(mode) = existing_mode { + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + fs::set_permissions(&models, fs::Permissions::from_mode(mode)).unwrap(); + } + let changes = reconcile_fixture(&models, Some("programs:\n pi: {}\n")); + if let Some(mode) = existing_mode { + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o777, + mode + ); + } else { + assert!(!models.exists(), "planning must not write files"); + } + changes.apply().unwrap(); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + if existing_mode.is_some() { + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!(merged["providers"]["personal"]["apiKey"], "test-secret"); + } + } + } + + #[cfg(unix)] + #[test] + fn models_cleanup_keeps_personal_credentials_private() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original = json!({"providers":{"personal":{"apiKey":"test-secret"}}}); + fs::write(&models, serde_json::to_vec(&original).unwrap()).unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + fs::set_permissions(&models, fs::Permissions::from_mode(0o600)).unwrap(); + + reconcile_fixture(&models, None).apply().unwrap(); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!(restored, original); + } + + #[cfg(unix)] + #[test] + fn models_reconcile_repairs_permissions_without_content_changes() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + let config = Some("programs:\n pi: {}\n"); + reconcile_fixture(&models, config).apply().unwrap(); + let before = fs::read(&models).unwrap(); + // Loosened after the merge, for example by another program. + fs::set_permissions(&models, fs::Permissions::from_mode(0o644)).unwrap(); + let changes = reconcile_fixture(&models, config); + assert!( + changes.render().contains("UPDATE Pi models"), + "{}", + changes.render() + ); + changes.apply().unwrap(); + assert_eq!(fs::read(&models).unwrap(), before); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + let repeated = reconcile_fixture(&models, config); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + } + + #[test] + fn models_dry_run_omits_file_contents() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + let config = "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n"; + let merge = reconcile_fixture(&models, Some(config)); + assert!(merge.render().contains("UPDATE Pi models")); + assert!( + !merge.render().contains("test-secret"), + "{}", + merge.render() + ); + merge.apply().unwrap(); + let removal = reconcile_fixture(&models, None); + assert!( + !removal.render().contains("test-secret"), + "{}", + removal.render() + ); + } + + #[test] + fn gateway_catalog_replaces_existing_models_and_restores_on_removal() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original = json!({"providers":{ + "agentdesktop": { + "baseUrl":"https://old.example.com", + "apiKey":"test-secret", + "models":[ + {"id":"selected","baseUrl":"https://gateway.example.com"}, + {"id":"selected","baseUrl":"https://outside.example.com"}, + {"id":"stale","baseUrl":"https://outside.example.com"} + ] + }, + "personal":{"models":[{"id":"personal-model"}],"apiKey":"personal-secret"} + }}); + fs::write(&models, serde_json::to_vec(&original).unwrap()).unwrap(); + + for selected in ["selected", "next"] { + let config = format!( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: {selected}\n" + ); + reconcile_fixture(&models, Some(&config)).apply().unwrap(); + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + merged["providers"]["agentdesktop"]["models"], + json!([ + {"id":selected,"baseUrl":"https://gateway.example.com"} + ]) + ); + assert_eq!( + merged["providers"]["personal"], + original["providers"]["personal"] + ); + let repeated = reconcile_fixture(&models, Some(&config)); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + } + + // Unrelated user edits made while managed must survive cleanup. + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + edited["providers"]["personal"]["apiKey"] = json!("updated-personal-secret"); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + reconcile_fixture(&models, None).apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + restored["providers"]["agentdesktop"], + original["providers"]["agentdesktop"] + ); + assert_eq!( + restored["providers"]["personal"]["apiKey"], + "updated-personal-secret" + ); + assert!(!super::json_merge::state_path(&models).exists()); + } + + #[test] + fn cleanup_restores_catalog_order_after_user_additions() { + for restore_original in [false, true] { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + // Pi resolves duplicate IDs using the last entry. The original catalog + // must keep that ordering even if one entry matches the managed model. + let original_catalog = json!([ + {"id":"selected","baseUrl":"https://outside.example.com"}, + {"id":"selected","baseUrl":"https://gateway.example.com"}, + {"id":"selected","baseUrl":"https://gateway.example.com"} + ]); + fs::write( + &models, + serde_json::to_vec(&json!({"providers":{"agentdesktop":{ + "models":original_catalog + }}})) + .unwrap(), + ) + .unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let addition = json!({"id":"added","baseUrl":"https://added.example.com"}); + if restore_original { + // A user may restore an original entry before disabling management. + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(original_catalog[0].clone()); + } + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(addition.clone()); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + + reconcile_fixture(&models, None).apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let mut expected = original_catalog; + expected.as_array_mut().unwrap().push(addition); + assert_eq!(restored["providers"]["agentdesktop"]["models"], expected); + } + } + + #[test] + fn disabling_gateway_restores_catalog_and_preserves_user_additions() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original_model = json!({"id":"original","baseUrl":"https://original.example.com"}); + fs::write( + &models, + serde_json::to_vec(&json!({"providers":{"agentdesktop":{ + "models":[original_model.clone()] + }}})) + .unwrap(), + ) + .unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + edited["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap() + .len(), + 1 + ); + let added_model = json!({"id":"added","baseUrl":"https://added.example.com"}); + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(added_model.clone()); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + + reconcile_fixture( + &models, + Some("programs:\n pi:\n useLlmGateway: false\n"), + ) + .apply() + .unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let catalog = restored["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap(); + assert_eq!(catalog.len(), 2); + assert!(catalog.contains(&original_model)); + assert!(catalog.contains(&added_model)); + reconcile_fixture(&models, None).apply().unwrap(); + assert_eq!( + serde_json::from_slice::(&fs::read(&models).unwrap()).unwrap(), + restored + ); + } + + #[test] + fn gateway_overrides_per_model_endpoints() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com/proxy +programs: + pi: + model: claude + models: + claude: + baseUrl: https://outside.example.com + chat: + api: openai-completions + baseUrl: https://outside.example.com/v1 + responses: + api: openai-responses +"#, + ) + .unwrap(); + let managed = managed_models( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + ) + .unwrap(); + for model in managed["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap() + { + let expected = if model["id"] == "claude" { + "https://gateway.example.com/proxy" + } else { + "https://gateway.example.com/proxy/v1" + }; + assert_eq!(model["baseUrl"], expected, "model {}", model["id"]); + } + } + + #[test] + fn model_map_keys_define_catalog_ids() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: selected + models: + selected: + id: different + name: Selected Model +"#, + ) + .unwrap(); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref(); + let managed = managed_models( + pi, + gateway, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + ) + .unwrap(); + let settings = managed_settings(pi, gateway).unwrap(); + let model = &managed["providers"]["agentdesktop"]["models"][0]; + assert_eq!(model["id"], settings["defaultModel"]); + assert_eq!(model["name"], "Selected Model"); + } + + #[test] + fn commented_models_survive_merge_repeat_and_removal() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + fs::write( + &models, + "\u{feff}{\n // Personal provider\n \"providers\": {\"local\": {\"baseUrl\": \"http://localhost:11434/v1\",},},\n}\n", + ) + .unwrap(); + fs::write(&settings, r#"{"theme":"dark"}"#).unwrap(); + let config = parse_daemon( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n", + ) + .unwrap(); + let configured = Some(( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + )); + let helper = root.path().join("agentdesktop"); + let socket = root.path().join("agentdesktop.sock"); + let changes = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, configured, &changes).unwrap(); + assert!(!changes.has_conflicts(), "Pi accepts commented models.json"); + changes.apply().unwrap(); + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + merged["providers"]["local"]["baseUrl"], + "http://localhost:11434/v1" + ); + assert_eq!( + merged["providers"]["agentdesktop"]["models"][0]["id"], + "selected" + ); + + let before = fs::read(&models).unwrap(); + let repeated = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, configured, &repeated).unwrap(); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + assert_eq!(fs::read(&models).unwrap(), before); + + // A user can add comments while agentdesktop manages the file. + fs::write( + &models, + format!("// Updated comment\n{}", String::from_utf8(before).unwrap()), + ) + .unwrap(); + let removal = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, None, &removal).unwrap(); + removal.apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + restored, + json!({"providers":{"local":{"baseUrl":"http://localhost:11434/v1"}}}) + ); + let restored_settings: Value = + serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + assert_eq!(restored_settings, json!({"theme":"dark"})); + assert!(!super::json_merge::state_path(&models).exists()); + assert!(!super::json_merge::state_path(&settings).exists()); + } + + #[test] + fn commented_settings_remain_a_conflict() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + let existing = b"// Pi settings require strict JSON\n{\"theme\":\"dark\"}"; + fs::write(&settings, existing).unwrap(); + let config = parse_daemon("programs:\n pi: {}\n").unwrap(); + let changes = ReconcilePlan::default(); + plan( + &models, + &settings, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.pi.as_ref().unwrap(), None)), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&settings).unwrap(), existing); + assert!(!models.exists()); + } + + #[test] + fn malformed_models_prevent_settings_writes() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + let invalid = b"{\"providers\":"; + fs::write(&models, invalid).unwrap(); + let config = parse_daemon("programs:\n pi: {}\n").unwrap(); + let changes = ReconcilePlan::default(); + plan( + &models, + &settings, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.pi.as_ref().unwrap(), None)), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&models).unwrap(), invalid); + assert!(!settings.exists()); + } + + #[test] + fn pass_through_settings_are_merged_with_managed_gateway_values() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com/proxy + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [pi] +programs: + pi: + model: claude-sonnet-4-5 + models: + claude-sonnet-4-5: + name: Company Claude + contextWindow: 200000 + claude-haiku-4-5: + name: Haiku + managedConfig: + providers: + ollama: + baseUrl: http://127.0.0.1:11434/v1 +"#, + ) + .expect("valid daemon configuration"); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref().unwrap(); + + let settings = managed_models( + pi, + Some(gateway), + Path::new("/usr/local/bin/agentdesktop"), + Path::new("/run/agentdesktop/agentdesktop.sock"), + ) + .expect("merged settings"); + + assert_eq!( + settings["providers"]["ollama"]["baseUrl"], + "http://127.0.0.1:11434/v1" + ); + let provider = &settings["providers"]["agentdesktop"]; + assert_eq!(provider["baseUrl"], "https://gateway.example.com/proxy"); + assert_eq!(provider["api"], "anthropic-messages"); + assert_eq!(provider["authHeader"], true); + let api_key = provider["apiKey"].as_str().unwrap(); + assert!(api_key.starts_with('!'), "{api_key}"); + #[cfg(unix)] + { + assert!(api_key.contains("credential"), "{api_key}"); + assert!(api_key.contains("--client-id"), "{api_key}"); + assert!(api_key.contains("/usr/local/bin/agentdesktop"), "{api_key}"); + } + let models = provider["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["id"], "claude-haiku-4-5"); + assert_eq!(models[1]["id"], "claude-sonnet-4-5"); + assert_eq!(models[1]["name"], "Company Claude"); + assert_eq!(models[1]["contextWindow"], 200000); + + let startup = managed_settings(pi, Some(gateway)).expect("startup settings"); + assert_eq!(startup["defaultProvider"], "agentdesktop"); + assert_eq!(startup["defaultModel"], "claude-sonnet-4-5"); + } + + #[test] + fn openai_compatible_gateways_append_v1() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: local-model + api: openai-completions +"#, + ) + .expect("valid daemon configuration"); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref().unwrap(); + let settings = managed_models( + pi, + Some(gateway), + Path::new("agentdesktop"), + Path::new("/tmp/agentdesktop.sock"), + ) + .expect("merged settings"); + assert_eq!( + settings["providers"]["agentdesktop"]["baseUrl"], + "https://gateway.example.com/v1" + ); + assert_eq!( + settings["providers"]["agentdesktop"]["api"], + "openai-completions" + ); + } +} diff --git a/crates/agent/src/provider/vscode/mod.rs b/crates/agent/src/provider/vscode/mod.rs index 8e5c7d97..b6c9fc97 100644 --- a/crates/agent/src/provider/vscode/mod.rs +++ b/crates/agent/src/provider/vscode/mod.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use agentdesktop_core::config::{DaemonConfig, VsCodeCopilotChat}; -use agentdesktop_core::model::Discovery; +use agentdesktop_core::model::{DaemonScope, Discovery}; use super::{Provider, ReconcileContext}; use crate::reconcile::ReconcilePlan; @@ -56,6 +56,14 @@ impl Provider for VsCode { } fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + if let Some(plan) = ctx.skip_unless( + DaemonScope::User, + config.programs.vscode.is_some(), + Self::DISPLAY_NAME, + ) { + return Ok(plan); + } + let configured = config.programs.vscode.as_ref().map(|program| { let gateway = config .llm_gateway @@ -68,10 +76,8 @@ impl Provider for VsCode { .as_ref() .map(|proxy| (proxy.address, &*proxy.pairing)); let plan = ReconcilePlan::default(); - // Both files live in the user's VS Code profile, so a system daemon has - // no paths and rejects the program before any provider writes. let paths = match (&self.chat_models_path, &self.settings_path) { - (Some(chat_models), Some(settings)) if ctx.merge_user_settings => { + (Some(chat_models), Some(settings)) => { Some((chat_models.as_path(), settings.as_path())) } _ => None, @@ -79,7 +85,7 @@ impl Provider for VsCode { let Some((chat_models_path, settings_path)) = paths else { if configured.is_some() { anyhow::bail!( - "{} Copilot Chat reads its custom models (chatLanguageModels.json) and the gateway override (settings.json) from the user's own VS Code profile; remove programs.vscode or run agentdesktop with --user (daemon.user: true) so it can manage those files", + "{} user configuration paths are unavailable", Self::DISPLAY_NAME ); } diff --git a/crates/agent/src/provider/vscode/reconcile.rs b/crates/agent/src/provider/vscode/reconcile.rs index acd9e16a..af29b7fb 100644 --- a/crates/agent/src/provider/vscode/reconcile.rs +++ b/crates/agent/src/provider/vscode/reconcile.rs @@ -77,6 +77,7 @@ fn options() -> json_merge::MergeOptions { keyed_arrays: KEYED, // The file holds the pairing. redact_diff: true, + ..json_merge::MergeOptions::default() } } diff --git a/crates/agent/src/provider/vscode/tests.rs b/crates/agent/src/provider/vscode/tests.rs index 91d37bbe..acd96b94 100644 --- a/crates/agent/src/provider/vscode/tests.rs +++ b/crates/agent/src/provider/vscode/tests.rs @@ -644,10 +644,10 @@ fn conflict_on_a_non_array_non_object_file() { assert_eq!(fs::read(&path).unwrap(), b"42\n"); } -// --- Provider::plan: system-mode rejection ---------------------------------- +// --- Provider::plan: system-mode skipping ---------------------------------- #[test] -fn system_mode_rejects_a_configured_program_before_writing_anything() { +fn system_mode_skips_a_configured_program_without_writing_anything() { use super::VsCode; use crate::provider::{Provider, ReconcileContext}; let dir = tempfile::tempdir().unwrap(); @@ -673,12 +673,15 @@ fn system_mode_rejects_a_configured_program_before_writing_anything() { settings_path: Some(path.with_file_name("settings.json")), }, ] { - let error = match provider.plan(&context(false), &config) { - Ok(_) => panic!("system mode with programs.vscode configured must fail"), - Err(error) => error, - }; - assert!(error.to_string().contains("--user"), "{error}"); - assert!(error.to_string().contains("VS Code"), "{error}"); + let plan = provider + .plan(&context(false), &config) + .expect("system mode skips this program"); + let reason = plan + .inactive_reason() + .expect("configured program is inactive"); + assert!(reason.contains("--user"), "{reason}"); + assert!(reason.contains("VS Code"), "{reason}"); + plan.apply().unwrap(); } assert!(!path.exists(), "preflight failure must not write any files"); } diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index dee29d4d..bfcf623d 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -6,7 +6,7 @@ pub(crate) use plan::grants_beyond; use crate::provider::{ Provider, ReconcileContext, claude_code::ClaudeCode, claude_desktop::ClaudeDesktop, codex::Codex, copilot::Copilot, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, - vscode::VsCode, + pi::Pi, vscode::VsCode, }; use agentdesktop_core::{ config::{DaemonConfig, ProgramsConfig}, @@ -60,6 +60,8 @@ impl Reconciler { copilot_providers_path: Option, vscode_chat_models_path: Option, vscode_settings_path: Option, + pi_models_path: Option, + pi_settings_path: Option, credential_helper: PathBuf, socket: PathBuf, ) -> Self { @@ -96,6 +98,10 @@ impl Reconciler { Box::new(Copilot { providers_path: copilot_providers_path, }), + Box::new(Pi { + models_path: pi_models_path, + settings_path: pi_settings_path, + }), Box::new(Ollama), ]), last_apply: Arc::new(std::sync::Mutex::new(None)), @@ -288,8 +294,8 @@ pub enum ProgramState { Removed, /// A managed file held conflicting or invalid existing configuration. Conflict, - /// The program uses the gateway, a gateway is configured, and the - /// loopback proxy is absent. + /// The daemon's mode cannot manage this program, or the program uses a + /// configured gateway but the loopback proxy is absent. Detail explains why. Inactive, /// The program had changes, but none of them was written because the /// apply was refused or stopped. @@ -337,9 +343,9 @@ impl ApplyReport { /// warning. pub fn log(&self) { for outcome in &self.programs { - // Inactive is a warning too: the program is configured for the - // gateway but its files are removed. The providers log the cause - // at debug level, since they plan on every apply. + // Inactive is a warning too: a configured program cannot be + // managed in this mode or cannot use the gateway. Detail explains + // the cause, including any effect on its existing files. let warning = matches!( outcome.state, ProgramState::Failed @@ -595,6 +601,7 @@ pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { grok, copilot, vscode, + pi, } = programs; [ (claude_code.is_some(), ClaudeCode::ID), @@ -604,6 +611,7 @@ pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { (grok.is_some(), Grok::ID), (copilot.is_some(), Copilot::ID), (vscode.is_some(), VsCode::ID), + (pi.is_some(), Pi::ID), ] .into_iter() .filter_map(|(configured, id)| configured.then_some(id)) @@ -613,6 +621,7 @@ pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { #[derive(Default)] struct DryRunReport { changes: RefCell>, + inactive: RefCell>, } struct DryRunChange { @@ -660,6 +669,10 @@ impl DryRunReport { .count(); let mut output = String::from("Dry run — no files will be changed\n"); + for reason in self.inactive.borrow().iter() { + let _ = write!(output, "\nINACTIVE {reason}\n"); + } + for change in changes.iter().filter(|change| change.action != "unchanged") { let _ = write!( output, @@ -750,58 +763,163 @@ mod tests { } #[test] - fn user_mode_rejects_claude_desktop_before_writing_other_settings() { - let root = std::env::temp_dir().join(format!( - "agentdesktop-reconcile-user-desktop-{}-{}", - std::process::id(), - rand::random::() - )); + fn mixed_mode_policy_applies_supported_programs_and_preserves_skipped_files() { let config = parse_daemon( r#" +llmGateway: + url: https://gateway.example.com programs: claudeCode: {} - claudeDesktop: {} + claudeDesktop: + model: selected + grok: + model: selected + pi: + model: selected + copilot: + models: + selected: {} + vscode: + models: + selected: {} "#, ) - .expect("valid configuration"); - let reconciler = Reconciler::new( - true, - root.join("claude/settings.json"), - root.join("claude-desktop/settings.json"), - root.join("claude-desktop/helper"), - root.join("codex/config.toml"), - root.join("opencode/config.json"), - root.join("opencode/plugin.js"), - root.join("grok/managed_config.toml"), - Some(root.join("copilot/providers.json")), - None, - None, - root.join("bin/agentdesktop"), - root.join("agentdesktop.sock"), - ); + .expect("mixed fleet policy is valid"); + let programs = [ + ("claude-desktop", "claude-desktop", false), + ("grok", "grok", false), + ("pi", "pi", true), + ("copilot", "copilot", true), + ("vscode", "vscode", true), + ]; + + fn snapshot(path: &Path) -> std::collections::BTreeMap> { + let mut files = std::collections::BTreeMap::new(); + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(snapshot(&path)); + } else { + files.insert(path.clone(), fs::read(path).unwrap()); + } + } + files + } - let error = reconciler.apply(&config).expect_err("user mode must fail"); + for user in [false, true] { + let root = tempfile::tempdir().unwrap(); + let mut reconciler = + full_reconciler(root.path()).with_llm_proxy(Some(proxy_context("PAIRING"))); + // Seed owned files in the opposite mode, including ownership sidecars. + reconciler.context.merge_user_settings = !user; + let mut seed = config.clone(); + seed.programs.claude_code = None; + reconciler + .apply(&seed) + .expect("seed the other mode's files"); + let skipped: Vec<_> = programs + .iter() + .filter(|(_, _, required_user)| *required_user != user) + .map(|(id, dir, _)| (*id, snapshot(&root.path().join(dir)))) + .collect(); + assert!(skipped.iter().all(|(_, files)| !files.is_empty())); + + reconciler.context.merge_user_settings = user; + let preview = reconciler.plan(&config).unwrap().render(); + assert!(preview.contains("INACTIVE"), "{preview}"); + assert!(preview.contains(if user { + "requires system mode" + } else { + "requires user mode" + })); + let (report, result) = reconciler.apply_with_report(&config); + result.expect("unsupported programs must not block supported programs"); + assert_eq!( + program_outcome(&report, "claude-code").unwrap().state, + ProgramState::Applied + ); + for (id, _, required_user) in programs { + let outcome = program_outcome(&report, id).unwrap(); + if required_user == user { + assert_eq!(outcome.state, ProgramState::Applied, "{id}"); + } else { + assert_eq!(outcome.state, ProgramState::Inactive, "{id}"); + assert!( + outcome + .detail + .contains(if user { "system mode" } else { "user mode" }), + "{}", + outcome.detail + ); + } + } + for (id, files) in &skipped { + assert_eq!(&snapshot(&root.path().join(id)), files, "{id}"); + } - assert!( - error - .to_string() - .contains("Claude Desktop managed settings") - ); - assert!(!root.exists(), "preflight failure must not write any files"); + let (report, result) = reconciler.apply_with_report(&parse_daemon("{}").unwrap()); + result.expect("cleanup only affects programs supported in this mode"); + for (id, files) in &skipped { + assert!( + program_outcome(&report, id).is_none(), + "disabled {id} has no status" + ); + assert_eq!(&snapshot(&root.path().join(id)), files, "{id}"); + } + } } #[test] - fn user_mode_rejects_grok_before_writing_other_settings() { + fn skipped_programs_do_not_read_unmanageable_paths() { + for (user, yaml, paths) in [ + ( + true, + "programs:\n claudeCode: {}\n claudeDesktop: {}\n grok: {}", + vec!["claude-desktop/settings.json", "grok/managed_config.toml"], + ), + ( + false, + "programs:\n claudeCode: {}\n pi: {}\n copilot: {}\n vscode: {}", + vec![ + "pi/models.json", + "copilot/providers.json", + "vscode/User/settings.json", + ], + ), + ] { + let root = tempfile::tempdir().unwrap(); + // Directories where files are expected would fail planning if read. + for path in &paths { + fs::create_dir_all(root.path().join(path)).unwrap(); + } + let mut reconciler = full_reconciler(root.path()); + reconciler.context.merge_user_settings = user; + let (report, result) = reconciler.apply_with_report(&parse_daemon(yaml).unwrap()); + result.expect("skipped programs do no I/O"); + assert_eq!( + program_outcome(&report, "claude-code").unwrap().state, + ProgramState::Applied + ); + for path in paths { + assert!(root.path().join(path).is_dir()); + } + } + } + + #[test] + fn user_mode_reconciles_pi_models_and_settings() { let root = std::env::temp_dir().join(format!( - "agentdesktop-reconcile-user-grok-{}-{}", + "agentdesktop-reconcile-user-pi-{}-{}", std::process::id(), rand::random::() )); let config = parse_daemon( r#" +llmGateway: + url: https://gateway.example.com programs: - claudeCode: {} - grok: {} + pi: + model: claude-sonnet-4-5 "#, ) .unwrap(); @@ -817,13 +935,45 @@ programs: Some(root.join("copilot/providers.json")), None, None, + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); - let error = reconciler.apply(&config).expect_err("user mode must fail"); - assert!(error.to_string().contains("Grok Build")); - assert!(!root.exists(), "preflight failure must not write any files"); + reconciler + .apply(&config) + .expect("Pi is supported in user mode"); + let models = String::from_utf8(fs::read(root.join("pi/models.json")).unwrap()).unwrap(); + assert!(models.contains("agentdesktop")); + assert!(models.contains("claude-sonnet-4-5")); + let settings = String::from_utf8(fs::read(root.join("pi/settings.json")).unwrap()).unwrap(); + assert!(settings.contains("defaultProvider")); + + let repeated = reconciler.plan(&config).unwrap(); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + assert_eq!( + fs::read_to_string(root.join("pi/models.json")).unwrap(), + models + ); + assert_eq!( + fs::read_to_string(root.join("pi/settings.json")).unwrap(), + settings + ); + + reconciler + .apply(&parse_daemon("programs: {}").unwrap()) + .unwrap(); + for path in [ + "models.json", + ".models.json.agentdesktop", + "settings.json", + ".settings.json.agentdesktop", + ] { + assert!(!root.join("pi").join(path).exists()); + } + fs::remove_dir_all(root).unwrap(); } #[test] @@ -859,6 +1009,8 @@ programs: Some(providers.clone()), None, None, + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -914,6 +1066,8 @@ programs: Some(root.join("copilot/providers.json")), Some(chat_models.clone()), Some(chat_models.with_file_name("settings.json")), + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -936,48 +1090,6 @@ programs: let _ = std::fs::remove_dir_all(&root); } - #[test] - fn system_mode_rejects_copilot_before_writing_other_settings() { - let root = std::env::temp_dir().join(format!( - "agentdesktop-reconcile-system-copilot-{}-{}", - std::process::id(), - rand::random::() - )); - let config = parse_daemon( - r#" -programs: - claudeCode: {} - copilot: {} -"#, - ) - .unwrap(); - // The Copilot CLI program is the inverse of Grok: it manages a file in - // the user's home, so a system daemon (no providers path) rejects it - // before any provider writes. - let reconciler = Reconciler::new( - false, - root.join("claude/settings.json"), - root.join("claude-desktop/settings.json"), - root.join("claude-desktop/helper"), - root.join("codex/config.toml"), - root.join("opencode/config.json"), - root.join("opencode/plugin.js"), - root.join("grok/managed_config.toml"), - None, - None, - None, - root.join("bin/agentdesktop"), - root.join("agentdesktop.sock"), - ); - - let error = reconciler - .apply(&config) - .expect_err("system mode must fail"); - assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); - assert!(error.to_string().contains("--user"), "{error}"); - assert!(!root.exists(), "preflight failure must not write any files"); - } - #[cfg(windows)] #[test] fn windows_reconciles_system_grok_managed_config() { @@ -1027,6 +1139,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -1071,6 +1185,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -1094,7 +1210,7 @@ llmGateway: authentication: type: controllerJwt audience: agentgateway - allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok] + allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, pi] programs: claudeCode: {} claudeDesktop: {} @@ -1247,6 +1363,8 @@ programs: Some(root.join("copilot/providers.json")), Some(root.join("vscode/User/chatLanguageModels.json")), Some(root.join("vscode/User/settings.json")), + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -1372,6 +1490,39 @@ programs: let _ = fs::remove_dir_all(&root); } + #[test] + fn pi_reports_applied_unchanged_removed_then_no_row() { + let root = new_root("pi"); + let configured = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 +"#, + ) + .unwrap(); + let disabled = parse_daemon("programs: {}").unwrap(); + let reconciler = full_reconciler(&root); + let mut states = Vec::new(); + for config in [&configured, &configured, &disabled, &disabled] { + let (report, result) = reconciler.apply_with_report(config); + result.expect("apply succeeds"); + states.push(program_outcome(&report, "pi").map(|outcome| outcome.state)); + } + assert_eq!( + states, + [ + Some(ProgramState::Applied), + Some(ProgramState::Unchanged), + Some(ProgramState::Removed), + None, + ] + ); + let _ = fs::remove_dir_all(&root); + } + #[test] fn use_llm_gateway_false_reports_applied_or_unchanged() { let root = new_root("use-llm-gateway-false"); @@ -1447,23 +1598,28 @@ programs: #[test] fn failed_provider_plan_does_not_stop_the_others() { let root = new_root("failed-plan-continues"); - // Grok Build's managed config lives in a system location, so a - // user-mode daemon rejects it in `plan()` (see - // `user_mode_rejects_grok_before_writing_other_settings` above) while - // Claude Code plans normally: a real, deterministic plan failure. + // A directory where Pi expects a file is a real I/O failure. Grok is + // skipped in user mode and must remain inactive even when apply fails. + fs::create_dir_all(root.join("pi/models.json")).unwrap(); let reconciler = full_reconciler(&root); - let config = parse_daemon("programs:\n claudeCode: {}\n grok: {}").unwrap(); + let config = parse_daemon("programs:\n claudeCode: {}\n pi: {}\n grok: {}").unwrap(); let (report, result) = reconciler.apply_with_report(&config); assert!(result.is_err()); - let grok = program_outcome(&report, "grok").expect("grok is reported"); - assert_eq!(grok.state, ProgramState::Failed); - assert!(grok.detail.contains("Grok Build"), "{}", grok.detail); assert_eq!( - program_outcome(&report, "claude-code").map(|outcome| outcome.state), - Some(ProgramState::Blocked), + program_outcome(&report, "pi").unwrap().state, + ProgramState::Failed + ); + assert_eq!( + program_outcome(&report, "grok").unwrap().state, + ProgramState::Inactive + ); + assert_eq!( + program_outcome(&report, "claude-code").unwrap().state, + ProgramState::Blocked, "claude-code was still planned, but nothing is written because the apply fails" ); + assert!(!root.join("claude/settings.json").exists()); let _ = fs::remove_dir_all(&root); } @@ -1699,6 +1855,7 @@ programs: "copilot", "vscode", "opencode", + "pi", ] { assert!( program_outcome(&report, absent).is_none(), diff --git a/crates/agent/src/reconcile/plan.rs b/crates/agent/src/reconcile/plan.rs index 333a5b4f..5a0a8d81 100644 --- a/crates/agent/src/reconcile/plan.rs +++ b/crates/agent/src/reconcile/plan.rs @@ -16,8 +16,8 @@ pub struct ReconcilePlan { operations: RefCell>, private_dirs: PrivateDirs, report: DryRunReport, - /// Why the program that owns this plan is configured but not pointed at - /// the gateway (the loopback proxy is absent); set by the provider. + /// Why the configured program cannot be managed in this daemon's mode or + /// cannot use the gateway (the loopback proxy is absent). inactive: RefCell>, } @@ -76,12 +76,20 @@ impl ReconcilePlan { self.apply_tracked().map_err(|(error, _)| error) } - /// Records that the program owning this plan uses the gateway, a gateway - /// is configured, and the loopback proxy is absent. + /// Records why the configured program is inactive. pub fn inactive(&self, reason: &str) { *self.inactive.borrow_mut() = Some(reason.to_owned()); } + /// A program outside this daemon's scope has no file operations. Keep its + /// reason in both per-program status and the combined dry-run report. + pub(crate) fn skipped(reason: &str) -> Self { + let plan = Self::default(); + plan.inactive(reason); + plan.report.inactive.borrow_mut().push(reason.to_owned()); + plan + } + /// The reason recorded by [`ReconcilePlan::inactive`], for tests. #[cfg(test)] pub(crate) fn inactive_reason(&self) -> Option { @@ -242,6 +250,10 @@ impl ReconcilePlan { .changes .get_mut() .extend(other.report.changes.into_inner()); + self.report + .inactive + .get_mut() + .extend(other.report.inactive.into_inner()); Ok(()) } diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index aac7c2f1..a038d7c8 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -1636,6 +1636,8 @@ mod tests { Some(root.join("copilot/providers.json")), None, None, + Some(root.join("pi/models.json")), + Some(root.join("pi/settings.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 1169cc59..0dc60ddd 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -88,6 +88,9 @@ pub struct DaemonStartupConfig { /// directory). #[serde(default)] pub vscode: VsCodeStartupConfig, + /// Pi paths. + #[serde(default)] + pub pi: PiStartupConfig, /// Local loopback LLM proxy. #[serde(default)] pub llm_proxy: LlmProxyStartupConfig, @@ -188,6 +191,19 @@ pub struct OpenCodeStartupConfig { pub plugin: Option, } +/// Local Pi paths. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PiStartupConfig { + /// Model catalog file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models: Option, + /// Settings file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + impl Default for DaemonConfig { fn default() -> Self { Self { @@ -648,6 +664,10 @@ fn default_heartbeat_interval() -> Duration { } /// Settings for developer tools managed by Agentdesktop. +/// +/// A policy can mix user-only and system-only programs. Each daemon skips +/// programs its mode cannot manage, reporting them as inactive with a reason. +/// Skipped programs' files are left untouched, including during cleanup. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -655,7 +675,8 @@ pub struct ProgramsConfig { /// Claude Code managed-settings configuration. Arbitrary keys are passed through directly. #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_code: Option, - /// Claude Desktop managed configuration. Arbitrary keys are passed through directly. + /// Claude Desktop system-managed configuration. User daemons skip it. + /// Arbitrary keys are passed through directly. #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_desktop: Option, /// Codex managed configuration. @@ -674,6 +695,11 @@ pub struct ProgramsConfig { /// loopback proxy, or GitHub's models through the gateway). #[serde(default, skip_serializing_if = "Option::is_none")] pub vscode: Option, + /// Pi coding-agent harness managed configuration. + /// + /// Managed in user mode (`--user`); system daemons skip it with an inactive reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi: Option, } impl ProgramsConfig { @@ -685,6 +711,7 @@ impl ProgramsConfig { && self.grok.is_none() && self.copilot.is_none() && self.vscode.is_none() + && self.pi.is_none() } } @@ -780,7 +807,7 @@ pub struct OpenCodeConfig { /// When generated LLM-gateway settings overlap with those values, /// agentdesktop's generated values take precedence. /// Only system mode is supported. Grok can delete or replace the user-level -/// managed file during startup, so `--user` rejects `programs.grok`. +/// managed file during startup, so user daemons skip it with an inactive reason. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -817,8 +844,8 @@ pub struct GrokConfig { /// managed-settings equivalent for providers, and users may have their own /// BYOK entries. See `provider::copilot::reconcile` in the agent crate for /// how this is turned into the merged document. User mode only: the file -/// lives in the user's Copilot directory, so a system daemon rejects the -/// program. +/// lives in the user's Copilot directory, so system daemons skip it with an +/// inactive reason. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -959,7 +986,7 @@ impl CopilotConfig { /// the daemon adds the gateway identity. See `provider::vscode::reconcile` /// and `provider::vscode::settings` in the agent crate for how each is turned /// into its merged document. User mode only: both files live in the user's -/// VS Code profile directory, so a system daemon rejects the program. +/// VS Code profile directory, so system daemons skip it with an inactive reason. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -1126,6 +1153,41 @@ impl VsCodeConfig { } } +/// Settings reconciled into Pi's user `models.json` and `settings.json`. +/// +/// Generated `providers.agentdesktop` values take precedence over overlapping +/// `managedConfig` keys. Pi stores configuration under `~/.pi/agent` (or +/// `PI_CODING_AGENT_DIR`), so management requires `--user`. System daemons skip it. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PiConfig { + /// Whether this program uses the top-level LLM gateway. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub use_llm_gateway: bool, + /// Model ID selected when pointing Pi at the LLM gateway. + /// + /// This is required when a top-level `llmGateway` is configured. If `models` + /// is empty, agentdesktop creates a catalog entry with this ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Extra Pi model objects, keyed by model ID. + /// + /// Each value is an arbitrary Pi `models.json` model object. The map key + /// sets its `id`. Generated gateway `baseUrl` and `apiKey` values take + /// precedence, including per-model URLs. When this map is non-empty, + /// `model` must name one of its keys. Every key must contain a + /// non-whitespace character. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, + /// Pi API dialect for the managed provider. Defaults to `anthropic-messages`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + /// Arbitrary values merged into Pi's `models.json`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub managed_config: BTreeMap, +} + /// Upstream authentication selected by a managed agent. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -1304,6 +1366,9 @@ fn validate_daemon( if programs.vscode.is_some() { anyhow::bail!("sandbox is not supported for VS Code"); } + if programs.pi.is_some() { + anyhow::bail!("sandbox is not supported for Pi"); + } } if let Some(gateway) = llm_gateway { if !matches!(gateway.url.scheme(), "http" | "https") { @@ -1528,6 +1593,22 @@ fn validate_daemon( } } } + if let Some(pi) = &programs.pi + && llm_gateway.is_some() + && pi.use_llm_gateway + { + let model = pi + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi requires model when llmGateway is configured")?; + if pi.models.keys().any(|model| model.trim().is_empty()) { + anyhow::bail!("Pi model IDs cannot be empty"); + } + if !pi.models.is_empty() && !pi.models.contains_key(model) { + anyhow::bail!("Pi model {model} is not declared in models"); + } + } Ok(()) } @@ -1581,6 +1662,22 @@ mod tests { assert!(super::parse_local_daemon("daemon: { stateDr: /tmp/device }").is_err()); } + #[test] + fn pi_startup_paths_are_local_only() { + let yaml = "daemon:\n pi:\n models: custom/pi-models.json\n settings: custom/pi-settings.json\n"; + let config = super::parse_local_daemon(yaml).expect("local Pi paths are supported"); + let value = serde_json::to_value(config).unwrap(); + assert_eq!(value["daemon"]["pi"]["models"], "custom/pi-models.json"); + assert_eq!(value["daemon"]["pi"]["settings"], "custom/pi-settings.json"); + assert!( + parse_daemon(yaml) + .unwrap_err() + .to_string() + .contains("only allowed in the local") + ); + assert!(super::parse_local_daemon("daemon: { pi: { model: wrong } }").is_err()); + } + #[test] fn daemon_configuration_supports_local_and_managed_options() { let document = r#" @@ -2111,4 +2208,95 @@ programs: .contains("Grok Build model missing is not declared in models") ); } + + #[test] + fn pi_parses_gateway_catalog_settings() { + let daemon = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 + api: anthropic-messages + models: + claude-sonnet-4-5: + name: Claude Sonnet +"#, + ) + .expect("valid Pi configuration"); + let pi = daemon.programs.pi.expect("Pi program"); + assert!(pi.use_llm_gateway); + assert_eq!(pi.model.as_deref(), Some("claude-sonnet-4-5")); + assert_eq!(pi.api.as_deref(), Some("anthropic-messages")); + assert!(pi.models.contains_key("claude-sonnet-4-5")); + } + + #[test] + fn pi_requires_a_model_when_using_the_gateway() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: {} +"#, + ) + .expect_err("Pi without a model should fail"); + + assert!( + error + .to_string() + .contains("Pi requires model when llmGateway is configured") + ); + } + + #[test] + fn pi_requires_a_declared_gateway_model_when_models_are_listed() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: missing + models: + available: {} +"#, + ) + .expect_err("undeclared Pi model should fail"); + + assert!( + error + .to_string() + .contains("Pi model missing is not declared in models") + ); + } + + #[test] + fn pi_rejects_empty_model_ids() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 + models: + claude-sonnet-4-5: {} + "": {} +"#, + ) + .expect_err("empty Pi model id should fail"); + + assert!(error.to_string().contains("Pi model IDs cannot be empty")); + } + + #[test] + fn accepts_mixed_user_and_system_programs() { + parse_daemon( + "programs:\n pi: {}\n claudeDesktop: {}\n grok: {}\n copilot: {}\n vscode: {}", + ) + .expect("each daemon manages the programs supported in its mode"); + } } diff --git a/examples/claude/README.md b/examples/claude/README.md index 1574beff..122b73e3 100644 --- a/examples/claude/README.md +++ b/examples/claude/README.md @@ -76,6 +76,20 @@ agentdesktop Now that agentdesktop is running, Claude Code can be run. It will show the configured `Managed by Agentdesktop` and direct traffic through the gateway. +## Pi + +To include Pi, uncomment `programs.pi` in `claude-code.yaml`. The same policy can +include Claude Desktop: user daemons manage Pi and skip Claude Desktop, while +system daemons manage Claude Desktop and skip Pi. Run the daemon as the user +who runs Pi: + +```console +agentdesktop daemon --user --config examples/claude/agentdesktop.yaml +``` + +After enrollment, run `pi`; the managed default model uses the gateway. Keep the +daemon running so Pi can request gateway credentials. + ## Stop the scenario Stop the foreground daemon and controller with Ctrl-C, then stop Dex and diff --git a/examples/claude/claude-code.yaml b/examples/claude/claude-code.yaml index b43aa366..93c23178 100644 --- a/examples/claude/claude-code.yaml +++ b/examples/claude/claude-code.yaml @@ -4,7 +4,7 @@ llmGateway: authentication: type: controllerJwt audience: "agentgateway" - allowedClientIds: [claude-code, claude-desktop, codex, opencode] + allowedClientIds: [claude-code, claude-desktop, codex, opencode, pi] telemetry: events: @@ -16,3 +16,7 @@ programs: companyAnnouncements: ["Managed by Agentdesktop"] claudeDesktop: isLocalDevMcpEnabled: true + # Pi requires --user; system daemons skip it. User daemons skip claudeDesktop. + # pi: + # useLlmGateway: true + # model: claude-sonnet-4-5 diff --git a/examples/standalone/README.md b/examples/standalone/README.md index d2372bb3..5e244bf7 100644 --- a/examples/standalone/README.md +++ b/examples/standalone/README.md @@ -80,13 +80,22 @@ listens on 127.0.0.1:4319 (`daemon.usage` in `config.yaml`) and keeps this machine's usage. Open the desktop app and select **Usage** to see tokens and cost by tool and model. +## Pi + +Uncomment `programs.pi` in `config.yaml` to enable Pi with `claude-sonnet-4-5`. +Run `pi` while the daemon is running to use that model through the gateway. +Pi requires `--user`; authenticated gateway configuration cannot be applied +with `--once` in user mode. Pi and Claude Desktop can share a policy: user +daemons skip Claude Desktop, and system daemons skip Pi, with an inactive reason. + + ## Claude Desktop In the Claude Code example, we ran with `--user`. This runs the daemon as an unprivileged user. Claude Desktop, however, requires root configuration. -Uncomment the Claude Desktop configuration in `config.yaml` and save the file. +Uncomment the Claude Desktop configuration in `config.yaml`, then save the file. Then run the daemon as root: ```sh diff --git a/examples/standalone/config.yaml b/examples/standalone/config.yaml index 0f0628e5..1783174f 100644 --- a/examples/standalone/config.yaml +++ b/examples/standalone/config.yaml @@ -18,6 +18,10 @@ programs: claudeCode: useLlmGateway: true companyAnnouncements: ["Using the local Agentgateway through Agentdesktop"] -# Claude desktop cannot run with --user. Uncomment to enable +# Pi is optional and requires --user. +# pi: +# useLlmGateway: true +# model: claude-sonnet-4-5 +# Claude Desktop requires system mode; user daemons skip it. # claudeDesktop: # useLlmGateway: true diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 8cdae11e..b0c97bf0 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -123,7 +123,8 @@ export type AgentKind = | "openCode" | "grok" | "copilot" - | "vscode"; + | "vscode" + | "pi"; export type AgentDraft = { kind: AgentKind; diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index 0722225e..e910fa40 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -585,6 +585,13 @@ const configurableAgents: Array<{ "copilotChat: githubModels\n# or own models through the gateway:\n# models:\n# gpt-4.1-mini:\n# maxInputTokens: 128000", initialSettings: "models:\n gpt-4.1-mini: {}", }, + { + kind: "pi", + label: "Pi", + iconKind: "pi", + placeholder: "model: claude-sonnet-4-5", + initialSettings: "model: claude-sonnet-4-5", + }, ]; const sandboxUnsupportedAgents = new Set([ @@ -593,6 +600,7 @@ const sandboxUnsupportedAgents = new Set([ "grok", "copilot", "vscode", + "pi", ]); function daemonConfigYaml(options: { @@ -625,7 +633,7 @@ function daemonConfigYaml(options: { " authentication:", " type: controllerJwt", ` audience: ${yamlString(options.audience)}`, - " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, copilot-cli, vscode-copilot]", + " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, copilot-cli, vscode-copilot, pi]", ); } lines.push(""); diff --git a/frontend/desktop/src/views/ToolsView.tsx b/frontend/desktop/src/views/ToolsView.tsx index 0f1c702c..48f2586c 100644 --- a/frontend/desktop/src/views/ToolsView.tsx +++ b/frontend/desktop/src/views/ToolsView.tsx @@ -96,8 +96,8 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) {
No supported tools found - Agent Desktop can inventory VS Code, Claude Code, Claude - Desktop, Codex, OpenCode, and Grok Build. + agentdesktop can inventory VS Code, Claude Code, Claude Desktop, + Codex, OpenCode, Grok Build, and Pi.
diff --git a/frontend/ui/src/assets/pi.svg b/frontend/ui/src/assets/pi.svg new file mode 100644 index 00000000..046fd463 --- /dev/null +++ b/frontend/ui/src/assets/pi.svg @@ -0,0 +1 @@ +Pi diff --git a/frontend/ui/src/tools.tsx b/frontend/ui/src/tools.tsx index 6ebce7f0..c1c1a094 100644 --- a/frontend/ui/src/tools.tsx +++ b/frontend/ui/src/tools.tsx @@ -15,6 +15,7 @@ import cursorIcon from "./assets/cursor.svg"; import grokIcon from "./assets/grok.svg"; import ollamaIcon from "./assets/ollama.svg"; import openCodeIcon from "./assets/opencode.svg"; +import piIcon from "./assets/pi.svg"; export interface ToolMcpServer { name: string; @@ -53,6 +54,7 @@ const toolIcons: Record = { "grok-build": grokIcon, cursor: cursorIcon, opencode: openCodeIcon, + pi: piIcon, vscode: copilotIcon, "vscode-copilot": copilotIcon, copilot: copilotIcon, @@ -69,6 +71,7 @@ export function friendlyTool(kind: string) { grok: "Grok Build", "grok-build": "Grok Build", opencode: "OpenCode", + pi: "Pi", vscode: "VS Code", "vscode-copilot": "VS Code", cursor: "Cursor", diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 5d6afb94..caa02cb8 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -176,7 +176,7 @@ ] }, "CopilotConfig": { - "description": "Settings reconciled into GitHub Copilot CLI's `providers.json`.\n\n`providers.json` is merged, never owned outright: the CLI has no\nmanaged-settings equivalent for providers, and users may have their own\nBYOK entries. See `provider::copilot::reconcile` in the agent crate for\nhow this is turned into the merged document. User mode only: the file\nlives in the user's Copilot directory, so a system daemon rejects the\nprogram.", + "description": "Settings reconciled into GitHub Copilot CLI's `providers.json`.\n\n`providers.json` is merged, never owned outright: the CLI has no\nmanaged-settings equivalent for providers, and users may have their own\nBYOK entries. See `provider::copilot::reconcile` in the agent crate for\nhow this is turned into the merged document. User mode only: the file\nlives in the user's Copilot directory, so system daemons skip it with an\ninactive reason.", "type": "object", "properties": { "models": { @@ -274,6 +274,11 @@ "$ref": "#/$defs/OpenCodeStartupConfig", "default": {} }, + "pi": { + "description": "Pi paths.", + "$ref": "#/$defs/PiStartupConfig", + "default": {} + }, "reconcileInterval": { "description": "Opt-in interval between periodic re-applies of the current\nconfiguration, which repair drift (a managed file deleted or edited by\nhand, a fixed conflict, a loosened mode) without rewriting anything\nunchanged. Unset means no periodic re-apply; a controller-managed\ndevice still re-applies the controller's configuration on every\nreconnect. Must be greater than zero and at most 30 days; read at\nstartup only (restart the daemon after changing it).", "type": [ @@ -357,7 +362,7 @@ ] }, "GrokConfig": { - "description": "Settings reconciled into Grok Build's organization-managed configuration.\n\nValues under `managedConfig` are written to Grok's `managed_config.toml`.\nWhen generated LLM-gateway settings overlap with those values,\nagentdesktop's generated values take precedence.\nOnly system mode is supported. Grok can delete or replace the user-level\nmanaged file during startup, so `--user` rejects `programs.grok`.", + "description": "Settings reconciled into Grok Build's organization-managed configuration.\n\nValues under `managedConfig` are written to Grok's `managed_config.toml`.\nWhen generated LLM-gateway settings overlap with those values,\nagentdesktop's generated values take precedence.\nOnly system mode is supported. Grok can delete or replace the user-level\nmanaged file during startup, so user daemons skip it with an inactive reason.", "type": "object", "properties": { "managedConfig": { @@ -583,6 +588,62 @@ }, "additionalProperties": false }, + "PiConfig": { + "description": "Settings reconciled into Pi's user `models.json` and `settings.json`.\n\nGenerated `providers.agentdesktop` values take precedence over overlapping\n`managedConfig` keys. Pi stores configuration under `~/.pi/agent` (or\n`PI_CODING_AGENT_DIR`), so management requires `--user`. System daemons skip it.", + "type": "object", + "properties": { + "api": { + "description": "Pi API dialect for the managed provider. Defaults to `anthropic-messages`.", + "type": [ + "string", + "null" + ] + }, + "managedConfig": { + "description": "Arbitrary values merged into Pi's `models.json`.", + "type": "object", + "additionalProperties": true + }, + "model": { + "description": "Model ID selected when pointing Pi at the LLM gateway.\n\nThis is required when a top-level `llmGateway` is configured. If `models`\nis empty, agentdesktop creates a catalog entry with this ID.", + "type": [ + "string", + "null" + ] + }, + "models": { + "description": "Extra Pi model objects, keyed by model ID.\n\nEach value is an arbitrary Pi `models.json` model object. The map key\nsets its `id`. Generated gateway `baseUrl` and `apiKey` values take\nprecedence, including per-model URLs. When this map is non-empty,\n`model` must name one of its keys. Every key must contain a\nnon-whitespace character.", + "type": "object", + "additionalProperties": true + }, + "useLlmGateway": { + "description": "Whether this program uses the top-level LLM gateway.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PiStartupConfig": { + "description": "Local Pi paths.", + "type": "object", + "properties": { + "models": { + "description": "Model catalog file.", + "type": [ + "string", + "null" + ] + }, + "settings": { + "description": "Settings file.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ProgramAuthentication": { "description": "Upstream authentication selected by a managed agent.", "oneOf": [ @@ -594,7 +655,7 @@ ] }, "ProgramsConfig": { - "description": "Settings for developer tools managed by Agentdesktop.", + "description": "Settings for developer tools managed by Agentdesktop.\n\nA policy can mix user-only and system-only programs. Each daemon skips\nprograms its mode cannot manage, reporting them as inactive with a reason.\nSkipped programs' files are left untouched, including during cleanup.", "type": "object", "properties": { "claudeCode": { @@ -609,7 +670,7 @@ ] }, "claudeDesktop": { - "description": "Claude Desktop managed configuration. Arbitrary keys are passed through directly.", + "description": "Claude Desktop system-managed configuration. User daemons skip it.\nArbitrary keys are passed through directly.", "anyOf": [ { "$ref": "#/$defs/ClaudeDesktopConfig" @@ -663,6 +724,17 @@ } ] }, + "pi": { + "description": "Pi coding-agent harness managed configuration.\n\nManaged in user mode (`--user`); system daemons skip it with an inactive reason.", + "anyOf": [ + { + "$ref": "#/$defs/PiConfig" + }, + { + "type": "null" + } + ] + }, "vscode": { "description": "VS Code Copilot Chat managed configuration (own models through the\nloopback proxy, or GitHub's models through the gateway).", "anyOf": [ @@ -814,7 +886,7 @@ ] }, "VsCodeConfig": { - "description": "Settings reconciled into VS Code's Copilot Chat configuration. Under\n`copilotChat: ownModels` this is `chatLanguageModels.json`, pointing\nCopilot Chat's built-in \"Custom Endpoint\" model provider at the local LLM\nproxy's `/vscode-copilot` route; under `copilotChat: githubModels`\nit is the user `settings.json`, pointing Copilot Chat's\nCAPI endpoint at the loopback proxy's `/vscode-copilot-capi/`\nroute so VS Code keeps GitHub's own models and its own Copilot token while\nthe daemon adds the gateway identity. See `provider::vscode::reconcile`\nand `provider::vscode::settings` in the agent crate for how each is turned\ninto its merged document. User mode only: both files live in the user's\nVS Code profile directory, so a system daemon rejects the program.", + "description": "Settings reconciled into VS Code's Copilot Chat configuration. Under\n`copilotChat: ownModels` this is `chatLanguageModels.json`, pointing\nCopilot Chat's built-in \"Custom Endpoint\" model provider at the local LLM\nproxy's `/vscode-copilot` route; under `copilotChat: githubModels`\nit is the user `settings.json`, pointing Copilot Chat's\nCAPI endpoint at the loopback proxy's `/vscode-copilot-capi/`\nroute so VS Code keeps GitHub's own models and its own Copilot token while\nthe daemon adds the gateway identity. See `provider::vscode::reconcile`\nand `provider::vscode::settings` in the agent crate for how each is turned\ninto its merged document. User mode only: both files live in the user's\nVS Code profile directory, so system daemons skip it with an inactive reason.", "type": "object", "properties": { "copilotChat": { diff --git a/schema/daemon-config.md b/schema/daemon-config.md index efc26d05..34b29b84 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -26,6 +26,9 @@ |`daemon.openCode`|object|OpenCode paths.| |`daemon.openCode.config`|string|Configuration file.| |`daemon.openCode.plugin`|string|Credential plugin path.| +|`daemon.pi`|object|Pi paths.| +|`daemon.pi.models`|string|Model catalog file.| +|`daemon.pi.settings`|string|Settings file.| |`daemon.reconcileInterval`|string|Opt-in interval between periodic re-applies of the current
configuration, which repair drift (a managed file deleted or edited by
hand, a fixed conflict, a loosened mode) without rewriting anything
unchanged. Unset means no periodic re-apply; a controller-managed
device still re-applies the controller's configuration on every
reconnect. Must be greater than zero and at most 30 days; read at
startup only (restart the daemon after changing it).| |`daemon.socket`|string|Local API Unix socket or Windows named pipe.| |`daemon.stateDir`|string|Persistent daemon state directory.| @@ -59,7 +62,7 @@ |`programs.claudeCode`|object|Claude Code managed-settings configuration. Arbitrary keys are passed through directly.| |`programs.claudeCode.auth`|enum|Upstream authentication used by this agent.
Possible values: `subscription`.| |`programs.claudeCode.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| -|`programs.claudeDesktop`|object|Claude Desktop managed configuration. Arbitrary keys are passed through directly.| +|`programs.claudeDesktop`|object|Claude Desktop system-managed configuration. User daemons skip it.
Arbitrary keys are passed through directly.| |`programs.claudeDesktop.auth`|enum|Upstream authentication used by this agent.
Possible values: `subscription`.| |`programs.claudeDesktop.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.codex`|object|Codex managed configuration.| @@ -80,6 +83,12 @@ |`programs.openCode.model`|string|Model ID selected from `models` when using the LLM gateway.

This is required when a top-level `llmGateway` is configured.| |`programs.openCode.models`|object|Models exposed by the managed LLM gateway provider, keyed by model ID.

Each value is an arbitrary OpenCode model configuration object. At least
one model is required when a top-level `llmGateway` is configured.| |`programs.openCode.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| +|`programs.pi`|object|Pi coding-agent harness managed configuration.

Managed in user mode (`--user`); system daemons skip it with an inactive reason.| +|`programs.pi.api`|string|Pi API dialect for the managed provider. Defaults to `anthropic-messages`.| +|`programs.pi.managedConfig`|object|Arbitrary values merged into Pi's `models.json`.| +|`programs.pi.model`|string|Model ID selected when pointing Pi at the LLM gateway.

This is required when a top-level `llmGateway` is configured. If `models`
is empty, agentdesktop creates a catalog entry with this ID.| +|`programs.pi.models`|object|Extra Pi model objects, keyed by model ID.

Each value is an arbitrary Pi `models.json` model object. The map key
sets its `id`. Generated gateway `baseUrl` and `apiKey` values take
precedence, including per-model URLs. When this map is non-empty,
`model` must name one of its keys. Every key must contain a
non-whitespace character.| +|`programs.pi.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.vscode`|object|VS Code Copilot Chat managed configuration (own models through the
loopback proxy, or GitHub's models through the gateway).| |`programs.vscode.copilotChat`|enum|Which Copilot Chat model source VS Code is pointed at: agentdesktop's
own custom models (`ownModels`), or GitHub's own models reached
through the gateway (`githubModels`).
Possible values: `ownModels`, `githubModels`.| |`programs.vscode.models`|object|Custom model entries exposed to VS Code's Copilot Chat model picker,
keyed by the model ID VS Code sends as `model`. Only meaningful under
`copilotChat: ownModels`; `githubModels` rejects a non-empty map.

At least one is required when a top-level `llmGateway` is configured
and `copilotChat` is `ownModels`.|