feat: guide protected enrollment and Hermes registration
This commit is contained in:
45
docs/setup.md
Normal file
45
docs/setup.md
Normal 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
209
src/cli.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
7
src/setup/help.rs
Normal 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
158
src/setup/hermes.rs
Normal 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
49
src/setup/mod.rs
Normal 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
282
src/setup/wizard.rs
Normal 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
217
tests/setup.rs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user