feat: guide protected enrollment and Hermes registration

This commit is contained in:
LucaPisl
2026-10-01 01:35:56 +03:00
parent 959eee2a10
commit 521087b4da
9 changed files with 977 additions and 1 deletions

45
docs/setup.md Normal file
View File

@@ -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.

209
src/cli.rs Normal file
View File

@@ -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<String>,
}
#[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<PathBuf>,
#[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<String> {
let mut i = Input::<String>::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<secrecy::SecretString> {
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<usize> {
Select::new()
.with_prompt(prompt)
.items(options)
.default(default)
.interact()
.map_err(|_| io_error())
}
fn confirm(&mut self, prompt: &str, default: bool) -> Result<bool> {
Confirm::new()
.with_prompt(prompt)
.default(default)
.interact()
.map_err(|_| io_error())
}
fn providers(&mut self) -> Result<Vec<providers::ProviderCandidate>> {
providers::discover_providers()
}
fn protection(&mut self, s: &ProfileSelection) -> protection::ProtectionEvidence {
protection::inspect_protection(&s.provider)
}
}
fn selection(args: &ProfileArgs, prefs: &PreferenceStore) -> Result<ProfileSelection> {
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<dyn CredentialStore> = 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(())
}

View File

@@ -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;

View File

@@ -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)
}
}

7
src/setup/help.rs Normal file
View File

@@ -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<String> {
let link = std::fs::read_link("/etc/localtime").ok()?;
let text = link.to_string_lossy();
text.split_once("zoneinfo/").map(|(_, s)| s.to_owned())
}

158
src/setup/hermes.rs Normal file
View File

@@ -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<Value> {
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<String> {
serde_json::to_string_pretty(&json!({"grocy":entry(exe,s)?})).map_err(|_| AppError::input())
}
async fn run(hermes: &Path, args: &[String]) -> Result<std::process::Output> {
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<std::path::PathBuf> {
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<Value> {
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
}

49
src/setup/mod.rs Normal file
View File

@@ -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<String>,
pub server_time: Value,
pub household_access: bool,
pub household_defaults: Value,
}
pub async fn probe(client: &ApiClient) -> Result<DoctorReport> {
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<dyn CredentialStore>) -> Result<DoctorReport> {
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})
}

282
src/setup/wizard.rs Normal file
View File

@@ -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<PathBuf>,
pub transport: TransportPolicy,
pub access: Option<AccessMode>,
pub connect_hermes: bool,
}
pub trait SetupUi {
fn message(&mut self, text: &str);
fn input(&mut self, prompt: &str, default: Option<&str>) -> Result<String>;
fn secret(&mut self, prompt: &str, allow_empty: bool) -> Result<SecretString>;
fn choose(&mut self, prompt: &str, options: &[String], default: usize) -> Result<usize>;
fn confirm(&mut self, prompt: &str, default: bool) -> Result<bool>;
fn providers(&mut self) -> Result<Vec<ProviderCandidate>>;
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<dyn CredentialStore>,
) -> Result<SetupOutcome> {
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::<Vec<_>>(),
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,
})
}

217
tests/setup.rs Normal file
View File

@@ -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<hermes_grocy_mcp::model::PrivateProfile>,
> {
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<String> {
self.inputs += 1;
Ok(self.url.clone())
}
fn secret(
&mut self,
_p: &str,
_empty: bool,
) -> hermes_grocy_mcp::error::Result<secrecy::SecretString> {
self.secrets += 1;
Ok(secrecy::SecretString::from("synthetic-key"))
}
fn choose(
&mut self,
_p: &str,
_o: &[String],
_d: usize,
) -> hermes_grocy_mcp::error::Result<usize> {
Ok(0)
}
fn confirm(&mut self, p: &str, _d: bool) -> hermes_grocy_mcp::error::Result<bool> {
Ok(p != "Save this profile?" || self.final_confirm)
}
fn providers(
&mut self,
) -> hermes_grocy_mcp::error::Result<
Vec<hermes_grocy_mcp::credentials::providers::ProviderCandidate>,
> {
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();
}