diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..50529c1 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,45 @@ +# Guided setup + +Build/install the executable using the README, then run: + +```sh +hermes-grocy-mcp setup +``` + +You normally enter only your Grocy address and a hidden API key. Arrow keys select a menu item; Enter accepts a suggested choice. Setup discovers your desktop wallets, suggests the appropriate provider, checks the API, discovers household metadata and your desktop time zone, and shows a final review before saving. It never asks you to search for an IANA timezone. + +## Prepare your Grocy account + +1. Ask your Grocy administrator to create a dedicated Hermes user in **Manage users**. Give it only the household permissions you want the assistant to use. Full household access can create, edit and delete records; custom field/entity definitions may require broader permissions. +2. Sign in as that user. In its user menu, open **Manage API keys**, add a key, and copy it into setup's hidden prompt. Use this key rather than an account password. Keys inherit that user's server permissions. +3. Strongly recommend enabling 2FA for the identity provider protecting your Grocy login, such as Authelia. An API key can bypass frontend 2FA: protect it independently, and revoke it in **Manage API keys** if compromised. + +Enter your normal HTTPS Grocy homepage, or its API address. A deployment prefix is preserved: `https://grocy.example/household` becomes `https://grocy.example/household/api`. Do not enter a UI page such as `/stockoverview`. All MCP requests use the API; an Authelia deployment needs to permit the enrolled `/api` prefix with Grocy API-key authentication. Complete Grocy's initial migration using its UI separately. + +## Your wallet + +Setup detects active and activatable Secret Service providers. With several providers it offers a choice; on KDE it recommends KWallet, on GNOME GNOME Keyring. It never changes providers silently or reads unrelated secrets. Open/unlock the selected default wallet in its own manager. Installing a manager is unnecessary if your wallet is already configured. + +- **KDE:** open KWallet Manager and the default wallet, often `kdewallet`. Use **File → Change Password** to set a non-empty password. Keep the wallet enabled. KWallet's file format cannot prove that its password is non-empty, so setup explains how to check and asks you to acknowledge that step. +- **GNOME:** open **Passwords and Keys** (Seahorse), select the default keyring and use **Change Password**. Set a non-empty password. Setup checks the available file header evidence; this is evidence about storage format, not a complete audit of the wallet. +- **Other providers:** protect/unlock their default collection using their native manager. Unsupported encryption is refused. + +If protection cannot be confirmed, the menu offers help, acknowledgment of a password you set, cancellation, or an explicit unsafe-storage override (default off). The override may permit plaintext *wallet storage*. It **never** permits plaintext application-to-wallet communication: the encrypted Secret Service session remains mandatory. The normal path stores the entire private profile, including address, key and household metadata, in your selected wallet. + +Wallet/network recovery keeps answers in memory for retries. Re-running setup reuses the selected provider, saved address/key and access mode, then asks before replacing the profile. A read-only enrollment is available with `setup --read-only`. To enable writes on a previously read-only profile, forget that profile and enroll it again, or use a separate profile (`setup --profile household`). + +## Hermes connection + +Setup uses the installed `hermes config` preserving writer to add `mcp_servers.grocy`, refuses a conflicting entry and runs `hermes mcp test grocy` (discovery only). Other settings are preserved. Run `/reload-mcp` in Hermes, or start a new session. Try: + +> Use grocy to list my stock. Do not change anything. + +If Hermes is unavailable or its interface differs, enrollment still completes. Put the installed `hermes` executable on your PATH and run `hermes-grocy-mcp connect-hermes`. Alternatively run `hermes-grocy-mcp print-config` and merge the printed `grocy` entry into `mcp_servers` in the file shown by `hermes config path`. The JSON entry is also valid YAML. It contains only executable, profile and provider identifiers—no key or server address. Keep the executable at the registered path. + +## Troubleshooting and removal + +`hermes-grocy-mcp doctor` checks the selected wallet and household API without writes. Unlock the same wallet before starting Hermes, including when using a system service; a service needs access to your user's desktop D-Bus session. Headless credential storage is not provided. + +Setup explains connection recovery: check the address, API bypass and key permissions; replace the key or select a trusted PEM CA file for a private certificate authority. There is no insecure TLS option. `--allow-loopback-http` is explicitly for local development and only accepts a literal loopback address, not a LAN host. + +`hermes-grocy-mcp forget` confirms removal of this application's matching wallet item and preferences. It does not revoke the server key or remove the Hermes entry. Revoke the key in Grocy and remove `mcp_servers.grocy` separately if you no longer use it. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..d5d7520 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,209 @@ +use crate::{ + credentials::{ + CredentialStore, DesktopStore, preferences::PreferenceStore, protection, providers, + }, + error::{AppError, Result}, + model::{AccessMode, ProfileSelection, TransportPolicy}, + setup::{SetupOptions, SetupUi}, +}; +use clap::{Parser, Subcommand}; +use dialoguer::{Confirm, Input, Password, Select}; +use std::{path::PathBuf, sync::Arc}; +#[derive(Parser)] +#[command( + version, + about = "Protected local household Grocy MCP. Start with setup." +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} +#[derive(clap::Args)] +pub struct ProfileArgs { + #[arg(long, default_value = "default")] + pub profile: String, + #[arg(long, help = "Use only this previously selected wallet service")] + pub provider: Option, +} +#[derive(Subcommand)] +pub enum Command { + #[command(about = "Guided wallet enrollment and Hermes connection; key input is hidden")] + Setup { + #[arg(long, default_value = "default")] + profile: String, + #[arg(long)] + ca_bundle: Option, + #[arg(long)] + read_only: bool, + #[arg(long)] + no_hermes: bool, + #[arg( + long, + help = "Explicit development enrollment: HTTP allowed only for a literal loopback destination" + )] + allow_loopback_http: bool, + }, + #[command(about = "Run the stdio MCP using an enrolled wallet profile")] + Serve(ProfileArgs), + #[command(about = "Check the wallet and API without household mutations")] + Doctor(ProfileArgs), + #[command(about = "Discover wallet providers without reading unrelated secrets")] + Wallets, + #[command(about = "Print a credential-free Hermes MCP entry")] + PrintConfig(ProfileArgs), + #[command(about = "Add the entry using Hermes's preserving writer and check discovery")] + ConnectHermes(ProfileArgs), + #[command( + about = "Remove only this application's local wallet profile; server-key revocation is separate" + )] + Forget(ProfileArgs), +} +fn io_error() -> AppError { + AppError::new( + "INPUT_CANCELLED", + "Input was cancelled or unavailable. Run setup in an interactive terminal.", + ) +} +pub struct ConsoleUi; +impl SetupUi for ConsoleUi { + fn message(&mut self, s: &str) { + println!("{s}") + } + fn input(&mut self, prompt: &str, default: Option<&str>) -> Result { + let mut i = Input::::new().with_prompt(prompt); + if let Some(d) = default { + i = i.default(d.into()) + } + i.interact_text().map_err(|_| io_error()) + } + fn secret(&mut self, prompt: &str, allow_empty: bool) -> Result { + Password::new() + .with_prompt(prompt) + .allow_empty_password(allow_empty) + .interact() + .map(secrecy::SecretString::from) + .map_err(|_| io_error()) + } + fn choose(&mut self, prompt: &str, options: &[String], default: usize) -> Result { + Select::new() + .with_prompt(prompt) + .items(options) + .default(default) + .interact() + .map_err(|_| io_error()) + } + fn confirm(&mut self, prompt: &str, default: bool) -> Result { + Confirm::new() + .with_prompt(prompt) + .default(default) + .interact() + .map_err(|_| io_error()) + } + fn providers(&mut self) -> Result> { + providers::discover_providers() + } + fn protection(&mut self, s: &ProfileSelection) -> protection::ProtectionEvidence { + protection::inspect_protection(&s.provider) + } +} +fn selection(args: &ProfileArgs, prefs: &PreferenceStore) -> Result { + let s = prefs.load(&args.profile)?; + if args + .provider + .as_ref() + .is_some_and(|p| p != &s.provider.bus_name) + { + return Err(AppError::new( + "WALLET_PROVIDER_CHANGED", + "The requested provider differs from enrollment. Run setup to select another wallet.", + )); + } + Ok(s) +} +pub async fn run(cli: Cli) -> Result<()> { + let prefs = PreferenceStore::default_location()?; + let store: Arc = Arc::new(DesktopStore); + match cli.command { + Command::Setup { + profile, + ca_bundle, + read_only, + no_hermes, + allow_loopback_http, + } => { + crate::setup::run( + SetupOptions { + profile, + preferences: prefs, + ca_path: ca_bundle, + access: if read_only { + Some(AccessMode::ReadOnly) + } else { + None + }, + transport: if allow_loopback_http { + TransportPolicy::LoopbackDevelopment + } else { + TransportPolicy::HttpsOnly + }, + connect_hermes: !no_hermes, + }, + &mut ConsoleUi, + store, + ) + .await?; + } + Command::Serve(a) => crate::mcp::serve_stdio(store, selection(&a, &prefs)?).await?, + Command::Doctor(a) => { + let report = crate::setup::doctor(selection(&a, &prefs)?, store).await?; + println!( + "Wallet profile loaded with encrypted communication. API reachable; household access checked. Grocy {}. No household changes made.", + report.version.as_deref().unwrap_or("version unknown") + ); + } + Command::Wallets => { + for p in providers::discover_providers()? { + println!( + "{}{}", + p.label, + if p.recommended { " (recommended)" } else { "" } + ); + } + } + Command::PrintConfig(a) => println!( + "{}", + crate::setup::hermes::render_config( + &std::env::current_exe().map_err(|_| io_error())?, + &selection(&a, &prefs)? + )? + ), + Command::ConnectHermes(a) => { + crate::setup::hermes::connect( + &std::env::current_exe().map_err(|_| io_error())?, + &selection(&a, &prefs)?, + ) + .await?; + println!( + "Hermes entry and discovery checked. Run /reload-mcp in Hermes or start a new session." + ); + } + Command::Forget(a) => { + let s = selection(&a, &prefs)?; + if Confirm::new() + .with_prompt( + "Remove this local Grocy profile? This does not revoke its server API key.", + ) + .default(false) + .interact() + .map_err(|_| io_error())? + { + store.forget(s).await?; + prefs.remove(&a.profile)?; + println!( + "Local profile removed. Revoke its API key in Grocy > Manage API keys; remove Hermes's grocy entry if no longer needed." + ); + } + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 562b9f3..e4bda8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ pub mod catalog; +pub mod cli; pub mod client; pub mod credentials; pub mod error; pub mod mcp; pub mod model; +pub mod setup; pub mod tools; diff --git a/src/main.rs b/src/main.rs index f328e4d..3e3ca58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1 +1,8 @@ -fn main() {} +use clap::Parser; +#[tokio::main] +async fn main() { + if let Err(error) = hermes_grocy_mcp::cli::run(hermes_grocy_mcp::cli::Cli::parse()).await { + eprintln!("{error}"); + std::process::exit(1) + } +} diff --git a/src/setup/help.rs b/src/setup/help.rs new file mode 100644 index 0000000..707fb37 --- /dev/null +++ b/src/setup/help.rs @@ -0,0 +1,7 @@ +pub const ACCOUNT_HELP: &str = "Before you begin:\n 1. In Grocy, an administrator can create a dedicated Hermes user under Manage users.\n 2. Grant only the household features you want Hermes to use. API keys inherit this user's permissions. Some custom-field/entity administration may require broader Grocy permissions.\n 3. Sign in as that user. Open the user menu > Manage API keys > Add, and copy its key. Use an API key, not your account password.\n 4. Strongly recommend 2FA for your identity-provider account where available (for example Authelia). An API key can bypass frontend 2FA; protect and revoke it separately.\n\nHermes and its model provider can retain returned household data in history. This MCP does not keep a household-data cache.\n"; +pub const WALLET_HELP: &str = "Protect your desktop wallet before storing the Grocy key.\nKDE: open KWallet Manager, open your default wallet (often kdewallet), and use File > Change Password. Set a non-empty password and keep the wallet enabled. Open/unlock it in the manager before using Hermes. KWallet's file format cannot prove whether its password is empty.\nGNOME: open Passwords and Keys (Seahorse), select the default keyring, and choose Change Password. Set a non-empty password and unlock it.\nOther Secret Service providers: protect and unlock the default collection in that provider's own manager.\nEncrypted application-to-wallet communication is always required, even if you accept unsafe storage.\n"; +pub fn detected_timezone() -> Option { + let link = std::fs::read_link("/etc/localtime").ok()?; + let text = link.to_string_lossy(); + text.split_once("zoneinfo/").map(|(_, s)| s.to_owned()) +} diff --git a/src/setup/hermes.rs b/src/setup/hermes.rs new file mode 100644 index 0000000..ac9ed35 --- /dev/null +++ b/src/setup/hermes.rs @@ -0,0 +1,158 @@ +use crate::{ + error::{AppError, Result}, + model::ProfileSelection, +}; +use serde_json::{Value, json}; +use std::{path::Path, process::Stdio, time::Duration}; +fn entry(exe: &Path, s: &ProfileSelection) -> Result { + crate::credentials::preferences::validate_profile(&s.profile)?; + if !exe.is_absolute() { + return Err(AppError::input()); + } + Ok( + json!({"command":exe,"args":["serve","--profile",s.profile,"--provider",s.provider.bus_name],"timeout":120}), + ) +} +pub fn render_config(exe: &Path, s: &ProfileSelection) -> Result { + serde_json::to_string_pretty(&json!({"grocy":entry(exe,s)?})).map_err(|_| AppError::input()) +} +async fn run(hermes: &Path, args: &[String]) -> Result { + let child = tokio::process::Command::new(hermes) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|_| { + AppError::new( + "HERMES_UNAVAILABLE", + "Hermes is unavailable. Install it or use print-config.", + ) + })?; + let output = tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()) + .await + .map_err(|_| { + AppError::new( + "HERMES_TIMEOUT", + "Hermes did not finish. Retry connect-hermes; the wallet profile is still saved.", + ) + })? + .map_err(|_| AppError::new("HERMES_UNAVAILABLE", "Hermes did not finish this command."))?; + if output.stdout.len() > 1024 * 1024 { + return Err(AppError::new( + "HERMES_CONFIG_UNSUPPORTED", + "Hermes configuration output is too large. Use print-config.", + )); + } + Ok(output) +} +async fn config_path(hermes: &Path) -> Result { + let o = run(hermes, &["config".into(), "path".into()]).await?; + let text = std::str::from_utf8(&o.stdout) + .map_err(|_| AppError::input())? + .trim(); + let p = std::path::PathBuf::from(text); + if !o.status.success() || text.contains('\n') || !p.is_absolute() { + return Err(AppError::new( + "HERMES_CONFIG_UNSUPPORTED", + "Hermes cannot report its configuration path. Update it or use print-config.", + )); + } + if let Ok(m) = std::fs::symlink_metadata(&p) { + if !m.is_file() { + return Err(AppError::new( + "HERMES_CONFIG_UNSUPPORTED", + "Hermes configuration must be a regular file. Review it manually.", + )); + } + } + Ok(p) +} +async fn servers(hermes: &Path) -> Result { + let o = run( + hermes, + &[ + "config".into(), + "get".into(), + "mcp_servers".into(), + "--json".into(), + ], + ) + .await?; + if !o.status.success() { + if String::from_utf8_lossy(&o.stdout).contains("Config key not set: mcp_servers") + || String::from_utf8_lossy(&o.stderr).contains("Config key not set: mcp_servers") + { + return Ok(json!({})); + } + return Err(AppError::new( + "HERMES_CONFIG_UNSUPPORTED", + "Cannot read Hermes MCP configuration. Update Hermes or use print-config.", + )); + } + let v: Value = serde_json::from_slice(&o.stdout).map_err(|_| { + AppError::new( + "HERMES_CONFIG_UNSUPPORTED", + "Hermes MCP configuration is not supported. Use print-config.", + ) + })?; + if !v.is_object() { + return Err(AppError::input()); + } + Ok(v) +} +pub async fn connect_using(hermes: &Path, exe: &Path, s: &ProfileSelection) -> Result<()> { + let path = config_path(hermes).await?; + let expected = entry(exe, s)?; + let before = servers(hermes).await?; + if let Some(existing) = before.get("grocy") { + if existing != &expected { + return Err(AppError::new( + "HERMES_ENTRY_CONFLICT", + "Hermes already has a different grocy entry. It was left untouched. Review it or use another Hermes profile.", + )); + } + } else { + if config_path(hermes).await? != path || servers(hermes).await? != before { + return Err(AppError::new( + "HERMES_CONFIG_CHANGED", + "Hermes configuration changed during setup. Retry after reviewing the active profile.", + )); + } + let o = run( + hermes, + &[ + "config".into(), + "set".into(), + "mcp_servers.grocy".into(), + expected.to_string(), + ], + ) + .await?; + if !o.status.success() { + return Err(AppError::new( + "HERMES_WRITE_FAILED", + "Hermes could not save its entry. The wallet profile remains available; retry connect-hermes.", + )); + } + } + if config_path(hermes).await? != path || servers(hermes).await?.get("grocy") != Some(&expected) + { + return Err(AppError::new( + "HERMES_WRITE_UNCONFIRMED", + "The configuration write could not be confirmed. Review it before repeating setup.", + )); + } + let o = run(hermes, &["mcp".into(), "test".into(), "grocy".into()]).await?; + if !o.status.success() { + return Err(AppError::new( + "HERMES_DISCOVERY_FAILED", + "The entry is saved but tool discovery failed. Open your wallet and retry connect-hermes; no need to enroll again.", + )); + } + Ok(()) +} +pub async fn connect(exe: &Path, s: &ProfileSelection) -> Result<()> { + connect_using(Path::new("hermes"), exe, s).await +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 0000000..3035b27 --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,49 @@ +pub mod help; +pub mod hermes; +mod wizard; +use crate::{ + client::{ApiClient, ApiRequest}, + credentials::CredentialStore, + error::Result, + model::ProfileSelection, +}; +use serde_json::{Value, json}; +use std::sync::Arc; +pub use wizard::{SetupOptions, SetupOutcome, SetupUi, run}; +pub struct DoctorReport { + pub version: Option, + pub server_time: Value, + pub household_access: bool, + pub household_defaults: Value, +} +pub async fn probe(client: &ApiClient) -> Result { + let info = client + .request(ApiRequest::get("/system/info")) + .await? + .json()?; + let time = client + .request(ApiRequest::get("/system/time")) + .await? + .json()?; + let mut defaults = serde_json::Map::new(); + for entity in ["locations", "quantity_units", "shopping_lists"] { + let mut r = ApiRequest::get("/objects/{entity}"); + r.params.insert("entity".into(), entity.into()); + r.query.insert("limit".into(), "100".into()); + defaults.insert(entity.into(), client.request(r).await?.json()?); + } + Ok(DoctorReport { + version: info["grocy_version"]["Version"].as_str().map(str::to_owned), + server_time: time, + household_access: true, + household_defaults: Value::Object(defaults), + }) +} +pub async fn doctor(s: ProfileSelection, store: Arc) -> Result { + let p = store.load(s).await?; + let client = ApiClient::new(p)?; + probe(&client).await +} +pub fn private_defaults(report: &DoctorReport) -> Value { + json!({"grocy_version":report.version,"server_time":report.server_time,"household_defaults":report.household_defaults}) +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs new file mode 100644 index 0000000..2b05b97 --- /dev/null +++ b/src/setup/wizard.rs @@ -0,0 +1,282 @@ +use crate::{ + client::{ApiBase, ApiClient}, + credentials::{ + CredentialStore, EnrollmentConsent, preferences::PreferenceStore, + protection::ProtectionEvidence, providers::ProviderCandidate, + }, + error::{AppError, Result}, + model::{AccessMode, PrivateProfile, ProfileSelection, TransportPolicy}, +}; +use secrecy::{ExposeSecret, SecretString}; +use std::{path::PathBuf, sync::Arc}; +pub struct SetupOptions { + pub profile: String, + pub preferences: PreferenceStore, + pub ca_path: Option, + pub transport: TransportPolicy, + pub access: Option, + pub connect_hermes: bool, +} +pub trait SetupUi { + fn message(&mut self, text: &str); + fn input(&mut self, prompt: &str, default: Option<&str>) -> Result; + fn secret(&mut self, prompt: &str, allow_empty: bool) -> Result; + fn choose(&mut self, prompt: &str, options: &[String], default: usize) -> Result; + fn confirm(&mut self, prompt: &str, default: bool) -> Result; + fn providers(&mut self) -> Result>; + fn protection(&mut self, s: &ProfileSelection) -> ProtectionEvidence; +} +pub struct SetupOutcome { + pub selection: ProfileSelection, + pub connected: bool, +} +fn cancelled() -> AppError { + AppError::new("CANCELLED", "Setup cancelled before saving a new profile.") +} +pub async fn run( + options: SetupOptions, + ui: &mut dyn SetupUi, + store: Arc, +) -> Result { + crate::credentials::preferences::validate_profile(&options.profile)?; + ui.message(super::help::ACCOUNT_HELP); + let previous = match options.preferences.load(&options.profile) { + Ok(s) => Some(s), + Err(e) if e.code == "PROFILE_NOT_FOUND" => None, + Err(e) => return Err(e), + }; + let selection = loop { + let candidates = ui.providers()?; + let default = candidates + .iter() + .position(|p| { + previous + .as_ref() + .is_some_and(|s| p.identity.as_ref() == Some(&s.provider)) + }) + .or_else(|| candidates.iter().position(|p| p.recommended)) + .unwrap_or(0); + if candidates.is_empty() { + ui.message(super::help::WALLET_HELP); + if ui.confirm( + "No wallet is running. Open its manager, then check again?", + true, + )? { + continue; + } else { + return Err(cancelled()); + } + } + let index = if candidates.len() == 1 && candidates[0].identity.is_some() { + 0 + } else { + ui.choose( + "Choose the wallet", + &candidates + .iter() + .map(|p| { + format!( + "{}{}", + p.label, + if p.recommended { + " (recommended for this desktop)" + } else { + "" + } + ) + }) + .collect::>(), + default, + )? + }; + let Some(provider) = candidates.get(index).and_then(|p| p.identity.clone()) else { + ui.message(super::help::WALLET_HELP); + if ui.confirm("Open the wallet manager, then check again?", true)? { + continue; + } else { + return Err(cancelled()); + } + }; + ui.message(&format!("Using {}.", candidates[index].label)); + break ProfileSelection { + profile: options.profile.clone(), + provider, + }; + }; + let consent = match ui.protection(&selection) { + ProtectionEvidence::Protected => { + ui.message("Password-protected wallet storage detected."); + EnrollmentConsent::Verified + } + e => { + ui.message(if e==ProtectionEvidence::Unprotected{"This wallet currently stores secrets without password protection."}else{"This wallet does not expose proof of password protection. Check its password using the steps below."}); + loop { + let choice = ui.choose( + "Wallet protection", + &[ + "Show how to protect the wallet".into(), + "I have set a non-empty password in its manager".into(), + "Proceed with potentially unprotected storage (unsafe)".into(), + "Cancel".into(), + ], + 0, + )?; + match choice{0=>ui.message(super::help::WALLET_HELP),1 if e!=ProtectionEvidence::Unprotected=>break EnrollmentConsent::ProtectedByUser,1=>{if ui.protection(&selection)==ProtectionEvidence::Protected{break EnrollmentConsent::Verified}ui.message("Protection is still unconfirmed. Reopen setup after protecting this wallet, or explicitly choose the unsafe option.")},2 if ui.confirm("Accept that this wallet may store your private profile and API key in plaintext?",false)?=>break EnrollmentConsent::UnsafeBackendAccepted,3=>return Err(cancelled()),_=>()} + } + } + }; + let mut old = None; + if previous.as_ref() == Some(&selection) { + loop { + match store.load(selection.clone()).await { + Ok(p) => { + old = Some(p); + break; + } + Err(e) if e.code == "PROFILE_NOT_FOUND" => break, + Err(e) => { + ui.message(&e.to_string()); + ui.message(super::help::WALLET_HELP); + if !ui.confirm("Open/unlock the selected wallet, then retry?", true)? { + return Err(cancelled()); + } + } + } + } + } + let mut url = ui.input( + "Grocy address (homepage or /api URL)", + old.as_ref().map(|p| p.api_base.as_str()), + )?; + let entered = ui.secret( + if old.is_some() { + "API key (Enter keeps the saved key)" + } else { + "API key from Manage API keys" + }, + old.is_some(), + )?; + let mut key = if entered.expose_secret().is_empty() { + SecretString::from( + old.as_ref() + .ok_or_else(AppError::input)? + .api_key + .expose_secret() + .to_owned(), + ) + } else { + entered + }; + let access = options + .access + .or_else(|| old.as_ref().map(|p| p.access)) + .unwrap_or(AccessMode::Full); + let mut ca_path = options + .ca_path + .or_else(|| old.as_ref().and_then(|p| p.ca_path.clone())); + let transport = old + .as_ref() + .map(|p| p.transport) + .unwrap_or(options.transport); + let (base, report) = loop { + let attempt = async { + let base = ApiBase::parse(&url, transport)?; + let p = PrivateProfile { + api_base: base.clone(), + api_key: SecretString::from(key.expose_secret().to_owned()), + access, + timezone: None, + defaults: serde_json::Value::Null, + ca_path: ca_path.clone(), + transport, + unsafe_storage: false, + }; + let c = ApiClient::new(p)?; + let report = super::probe(&c).await?; + Ok::<_, AppError>((base, report)) + } + .await; + match attempt { + Ok(result) => break result, + Err(e) => { + ui.message(&e.to_string()); + let retry = ui.choose( + "Connection recovery (your answers are kept)", + &[ + "Retry after checking Grocy / API bypass".into(), + "Change the address".into(), + "Replace the API key".into(), + "Use a trusted PEM CA file".into(), + "Cancel".into(), + ], + 0, + )?; + match retry { + 0 => (), + 1 => { + url = ui.input( + "Grocy address (remove UI page names such as /stockoverview)", + Some(&url), + )? + } + 2 => key = ui.secret("New API key", false)?, + 3 => ca_path = Some(ui.input("Trusted PEM CA file path", None)?.into()), + _ => return Err(cancelled()), + } + } + } + }; + let timezone = super::help::detected_timezone(); + ui.message(&format!("Ready to save profile '{}'.\nGrocy version: {}\nAccess: {}\nDesktop time zone: {} (timestamps use Grocy's server-local time or an explicit RFC3339 offset)\nWallet storage: {}\nCredentials stay in the selected wallet; the Hermes entry contains no API key or server URL.",options.profile,report.version.as_deref().unwrap_or("unknown"),if access==AccessMode::Full{"household reads, creates, edits, actions and deletes"}else{"household reads only"},timezone.as_deref().unwrap_or("server-local; no manual IANA search required"),if matches!(consent,EnrollmentConsent::UnsafeBackendAccepted){"UNSAFE override: the wallet may persist plaintext"}else{"protected or checked by you in its native manager"})); + if !ui.confirm("Save this profile?", true)? { + return Err(cancelled()); + } + loop { + let profile = PrivateProfile { + api_base: base.clone(), + api_key: SecretString::from(key.expose_secret().to_owned()), + access, + timezone: timezone.clone(), + defaults: super::private_defaults(&report), + ca_path: ca_path.clone(), + transport, + unsafe_storage: matches!(consent, EnrollmentConsent::UnsafeBackendAccepted), + }; + match store.put(selection.clone(), profile, consent).await { + Ok(()) => break, + Err(e) => { + ui.message(&e.to_string()); + ui.message(super::help::WALLET_HELP); + if !ui.confirm( + "Open/unlock the selected wallet, then retry saving? Your answers are kept.", + true, + )? { + return Err(cancelled()); + } + } + } + } + options.preferences.save(&selection)?; + ui.message("Profile saved in the selected wallet."); + let connected = if options.connect_hermes { + let exe = std::env::current_exe().map_err(|_| AppError::input())?; + match super::hermes::connect(&exe, &selection).await { + Ok(()) => { + ui.message("Hermes configuration and tool discovery checked. Run /reload-mcp in Hermes, or start a new session. Try: Use grocy to list my stock; do not change anything."); + true + } + Err(e) => { + ui.message(&e.to_string()); + ui.message("Enrollment is complete. Retry connect-hermes or use print-config; no need to enter the key again."); + ui.message(&super::hermes::render_config(&exe, &selection)?); + false + } + } + } else { + false + }; + Ok(SetupOutcome { + selection, + connected, + }) +} diff --git a/tests/setup.rs b/tests/setup.rs new file mode 100644 index 0000000..dd0d92d --- /dev/null +++ b/tests/setup.rs @@ -0,0 +1,217 @@ +use hermes_grocy_mcp::{ + model::{ProfileSelection, ProviderIdentity}, + setup::hermes, +}; +use std::os::unix::fs::PermissionsExt; +fn selection() -> ProfileSelection { + ProfileSelection { + profile: "default".into(), + provider: ProviderIdentity { + bus_name: "org.kde.secretservicecompat".into(), + executable: "/usr/bin/wallet".into(), + }, + } +} +#[tokio::test] +async fn guided_setup_and_hermes_preservation() { + let block = hermes::render_config( + std::path::Path::new("/opt/bin/hermes-grocy-mcp"), + &selection(), + ) + .unwrap(); + let data: serde_json::Value = serde_json::from_str(&block).unwrap(); + assert_eq!(data["grocy"]["command"], "/opt/bin/hermes-grocy-mcp"); + assert!(!block.contains("api_key")); + let temp = tempfile::tempdir().unwrap(); + let fake = temp.path().join("hermes"); + let state = temp.path().join("servers.json"); + std::fs::write(&state, "{\"other\":{\"command\":\"untouched\"}}").unwrap(); + let script = format!( + "#!/usr/bin/env python3\nimport json,sys,pathlib\np=pathlib.Path({:?})\na=sys.argv[1:]\nif a==['config','path']: print(str(p))\nelif a==['config','get','mcp_servers','--json']:print(p.read_text())\nelif a[:3]==['config','set','mcp_servers.grocy']:\n d=json.loads(p.read_text());d['grocy']=json.loads(a[3]);p.write_text(json.dumps(d))\nelif a==['mcp','test','grocy']:pass\nelse:sys.exit(2)\n", + state.to_string_lossy().to_string() + ); + std::fs::write(&fake, script).unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o700)).unwrap(); + hermes::connect_using( + &fake, + std::path::Path::new("/opt/bin/hermes-grocy-mcp"), + &selection(), + ) + .await + .unwrap(); + let data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state).unwrap()).unwrap(); + assert_eq!(data["other"]["command"], "untouched"); + assert_eq!(data["grocy"]["command"], "/opt/bin/hermes-grocy-mcp"); + let before = std::fs::read(&state).unwrap(); + let mut conflict = selection(); + conflict.profile = "other-profile".into(); + assert_eq!( + hermes::connect_using( + &fake, + std::path::Path::new("/opt/bin/hermes-grocy-mcp"), + &conflict + ) + .await + .err() + .unwrap() + .code, + "HERMES_ENTRY_CONFLICT" + ); + assert_eq!(std::fs::read(&state).unwrap(), before); +} + +struct RetryStore { + attempts: std::sync::atomic::AtomicUsize, +} +impl hermes_grocy_mcp::credentials::CredentialStore for RetryStore { + fn load( + &self, + _s: ProfileSelection, + ) -> futures_util::future::BoxFuture< + 'static, + hermes_grocy_mcp::error::Result, + > { + Box::pin(async { + Err(hermes_grocy_mcp::error::AppError::new( + "PROFILE_NOT_FOUND", + "Missing", + )) + }) + } + fn put( + &self, + _s: ProfileSelection, + p: hermes_grocy_mcp::model::PrivateProfile, + _c: hermes_grocy_mcp::credentials::EnrollmentConsent, + ) -> futures_util::future::BoxFuture<'static, hermes_grocy_mcp::error::Result<()>> { + use secrecy::ExposeSecret; + assert_eq!(p.api_key.expose_secret(), "synthetic-key"); + let attempt = self + .attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async move { + if attempt == 0 { + Err(hermes_grocy_mcp::error::AppError::new( + "WALLET_LOCKED", + "Locked", + )) + } else { + Ok(()) + } + }) + } + fn forget( + &self, + _s: ProfileSelection, + ) -> futures_util::future::BoxFuture<'static, hermes_grocy_mcp::error::Result<()>> { + Box::pin(async { Ok(()) }) + } +} +struct ScriptUi { + url: String, + inputs: usize, + secrets: usize, + final_confirm: bool, +} +impl hermes_grocy_mcp::setup::SetupUi for ScriptUi { + fn message(&mut self, _s: &str) {} + fn input(&mut self, _p: &str, _d: Option<&str>) -> hermes_grocy_mcp::error::Result { + self.inputs += 1; + Ok(self.url.clone()) + } + fn secret( + &mut self, + _p: &str, + _empty: bool, + ) -> hermes_grocy_mcp::error::Result { + self.secrets += 1; + Ok(secrecy::SecretString::from("synthetic-key")) + } + fn choose( + &mut self, + _p: &str, + _o: &[String], + _d: usize, + ) -> hermes_grocy_mcp::error::Result { + Ok(0) + } + fn confirm(&mut self, p: &str, _d: bool) -> hermes_grocy_mcp::error::Result { + Ok(p != "Save this profile?" || self.final_confirm) + } + fn providers( + &mut self, + ) -> hermes_grocy_mcp::error::Result< + Vec, + > { + Ok(vec![ + hermes_grocy_mcp::credentials::providers::ProviderCandidate { + label: "Synthetic wallet".into(), + identity: Some(selection().provider), + recommended: true, + }, + ]) + } + fn protection( + &mut self, + _s: &ProfileSelection, + ) -> hermes_grocy_mcp::credentials::protection::ProtectionEvidence { + hermes_grocy_mcp::credentials::protection::ProtectionEvidence::Protected + } +} +#[tokio::test] +async fn wallet_retry_preserves_setup_answers() { + use hermes_grocy_mcp::{ + credentials::preferences::PreferenceStore, model::TransportPolicy, setup, + }; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let app = axum::Router::new().fallback(|req: axum::extract::Request| async move { + assert!(req.uri().path().starts_with("/api/")); + axum::Json(if req.uri().path().ends_with("info") { + serde_json::json!({"grocy_version":{"Version":"4.7.1"}}) + } else { + serde_json::json!([]) + }) + }); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + let server = tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); + for approve in [false, true] { + let d = tempfile::tempdir().unwrap(); + let store = Arc::new(RetryStore { + attempts: AtomicUsize::new(0), + }); + let mut ui = ScriptUi { + url: format!("http://127.0.0.1:{port}"), + inputs: 0, + secrets: 0, + final_confirm: approve, + }; + let opts = setup::SetupOptions { + profile: "default".into(), + preferences: PreferenceStore { + directory: d.path().join("prefs"), + }, + ca_path: None, + transport: TransportPolicy::LoopbackDevelopment, + access: None, + connect_hermes: false, + }; + let result = setup::run(opts, &mut ui, store.clone()).await; + assert_eq!(ui.inputs, 1); + assert_eq!(ui.secrets, 1); + if approve { + assert!(result.is_ok()); + assert_eq!(store.attempts.load(Ordering::SeqCst), 2); + assert!(d.path().join("prefs/default.json").exists()); + } else { + assert_eq!(result.err().unwrap().code, "CANCELLED"); + assert_eq!(store.attempts.load(Ordering::SeqCst), 0); + assert!(!d.path().join("prefs/default.json").exists()); + } + } + server.abort(); +}