Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
appears as a standalone word in unrelated prose (e.g. "the id was ok") still satisfies the
check — this is a disclosed partial mitigation, not a full close of all accidental matches
(issue #6575).
- `src/commands/registry_client.rs`: `resolve_registry_token` no longer hardcodes `default_vault_dir()`
for the `Age` vault backend, ignoring any `--vault`/`--vault-key`/`--vault-path` CLI
overrides — every sibling vault-backed CLI path (`durable.rs`, `scheduler_daemon.rs`, per
#6590) already resolved these overrides, but the registry token lookup silently fell back to
the default vault directory even when the user pointed at a different vault (issue #6591).
`crate::bootstrap::resolve_vault_paths` is now threaded through `handle_skill_command` and
`handle_plugin_command` (`src/commands/skill.rs`, `src/commands/plugin.rs`) and their
registry search/get helpers, so registry authentication resolves against the same vault as
the rest of the command.
- `zeph-channels`: Telegram guest-mode responses (`TelegramChannel::flush_chunks`) are now
formatted through the same `markdown_to_telegram` renderer used by regular messages, and
sent with `parse_mode = "MarkdownV2"` instead of an unconverted `"HTML"` call — raw LLM
Expand Down
65 changes: 57 additions & 8 deletions src/commands/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ fn print_overlay_section(plugins_dir: &std::path::Path) -> anyhow::Result<()> {
// `async` regardless, since `runner.rs` always `.await`s this call and the feature is a
// caller-invisible build-time choice (M1, critic handoff).
#[allow(clippy::unused_async)]
#[allow(clippy::too_many_lines)]
pub(crate) async fn handle_plugin_command(
cmd: PluginCommand,
config_path: Option<&std::path::Path>,
vault_override: Option<&str>,
vault_key_override: Option<&std::path::Path>,
vault_path_override: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use crate::bootstrap::{load_config_or_default, resolve_config_path};

Expand Down Expand Up @@ -136,7 +140,14 @@ pub(crate) async fn handle_plugin_command(
PluginCommand::Search { query } => {
#[cfg(feature = "registry")]
{
registry_search(&config, &query).await?;
registry_search(
&config,
&query,
vault_override,
vault_key_override,
vault_path_override,
)
.await?;
}
#[cfg(not(feature = "registry"))]
{
Expand All @@ -155,11 +166,24 @@ pub(crate) async fn handle_plugin_command(
// they get the same reputation check (spec-043, #5864). No `--strict-reputation`
// flag on `get` — config's `enforcement` applies as-is.
let mgr = mgr.with_reputation_config(&config.plugins.reputation, false);
registry_get(&config, &mgr, &registry_id).await?;
registry_get(
&config,
&mgr,
&registry_id,
vault_override,
vault_key_override,
vault_path_override,
)
.await?;
}
#[cfg(not(feature = "registry"))]
{
let _ = &registry_id;
let _ = (
&registry_id,
vault_override,
vault_key_override,
vault_path_override,
);
println!(
"This zeph build was compiled without the `registry` feature; rebuild \
with `--features registry` (or `full`) to use `zeph plugin get`."
Expand All @@ -179,7 +203,13 @@ pub(crate) async fn handle_plugin_command(
/// [`registry_search_with`] — see that fn's tests for `MockRegistryClient`-driven coverage.
#[cfg(feature = "registry")]
#[tracing::instrument(name = "plugin.registry_search", skip(config), fields(query))]
async fn registry_search(config: &zeph_core::config::Config, query: &str) -> anyhow::Result<()> {
async fn registry_search(
config: &zeph_core::config::Config,
query: &str,
vault_override: Option<&str>,
vault_key_override: Option<&std::path::Path>,
vault_path_override: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use crate::commands::registry_client::{
REGISTRY_NOT_CONFIGURED_MSG, build_registry_client, resolve_registry_token,
};
Expand All @@ -189,7 +219,13 @@ async fn registry_search(config: &zeph_core::config::Config, query: &str) -> any
anyhow::bail!("{REGISTRY_NOT_CONFIGURED_MSG}");
}

let token = resolve_registry_token(config).await?;
let token = resolve_registry_token(
config,
vault_override,
vault_key_override,
vault_path_override,
)
.await?;
let client = build_registry_client(config, token);
registry_search_with(client.as_ref(), query).await
}
Expand Down Expand Up @@ -226,6 +262,9 @@ async fn registry_get(
config: &zeph_core::config::Config,
mgr: &zeph_plugins::PluginManager,
registry_id: &str,
vault_override: Option<&str>,
vault_key_override: Option<&std::path::Path>,
vault_path_override: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use crate::commands::registry_client::{
REGISTRY_NOT_CONFIGURED_MSG, build_registry_client, resolve_registry_token,
Expand All @@ -236,7 +275,13 @@ async fn registry_get(
anyhow::bail!("{REGISTRY_NOT_CONFIGURED_MSG}");
}

let token = resolve_registry_token(config).await?;
let token = resolve_registry_token(
config,
vault_override,
vault_key_override,
vault_path_override,
)
.await?;
let client = build_registry_client(config, token);
registry_get_with(client.as_ref(), mgr, registry_id).await
}
Expand Down Expand Up @@ -400,7 +445,9 @@ mod registry_tests {
let config = zeph_core::config::Config::default();
assert!(!config.skills.registry.enabled);

let err = registry_search(&config, "query").await.unwrap_err();
let err = registry_search(&config, "query", None, None, None)
.await
.unwrap_err();
assert_eq!(err.to_string(), REGISTRY_NOT_CONFIGURED_MSG);
}

Expand All @@ -414,7 +461,9 @@ mod registry_tests {
let config = zeph_core::config::Config::default();
assert!(!config.skills.registry.enabled);

let err = registry_get(&config, &mgr, "acme/x").await.unwrap_err();
let err = registry_get(&config, &mgr, "acme/x", None, None, None)
.await
.unwrap_err();
assert_eq!(err.to_string(), REGISTRY_NOT_CONFIGURED_MSG);
}
}
127 changes: 115 additions & 12 deletions src/commands/registry_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
//! `src/commands/skill.rs`/`plugin.rs` stay compilable with the feature off (M1, critic
//! handoff `.local/handoff/2026-07-10T19-29-13-critic.md`).

use std::path::Path;

use zeph_config::RegistryBackendKind;
use zeph_core::config::Config;
use zeph_core::vault::{
AgeVaultProvider, EnvVaultProvider, Secret, VaultProvider, default_vault_dir,
};
use zeph_core::vault::{AgeVaultProvider, EnvVaultProvider, Secret, VaultProvider};
use zeph_plugins::marketplace::RegistryClient;
use zeph_plugins::marketplace::skills_sh::{DEFAULT_BASE_URL, SkillsShClient};

Expand All @@ -29,12 +29,22 @@ use zeph_plugins::marketplace::skills_sh::{DEFAULT_BASE_URL, SkillsShClient};
/// not registered with the LLM-context secret-mask registry; low risk, since a one-shot CLI
/// invocation never feeds this value into an LLM-bound context.
///
/// `vault_override`/`vault_key_override`/`vault_path_override` are the `--vault`/`--vault-key`/
/// `--vault-path` CLI overrides, threaded through via [`crate::bootstrap::resolve_vault_paths`]
/// (#6591) — without them the `Age` backend silently ignored the overrides and always loaded
/// `default_vault_dir()`, diverging from every other vault-backed CLI command.
///
/// # Errors
///
/// Returns an error if the configured vault backend cannot be loaded (e.g. missing age key
/// file) or the lookup itself fails.
#[tracing::instrument(name = "registry_client.resolve_token", skip(config))]
pub(crate) async fn resolve_registry_token(config: &Config) -> anyhow::Result<Option<Secret>> {
pub(crate) async fn resolve_registry_token(
config: &Config,
vault_override: Option<&str>,
vault_key_override: Option<&Path>,
vault_path_override: Option<&Path>,
) -> anyhow::Result<Option<Secret>> {
let reg = &config.skills.registry;
if !reg.enabled {
return Ok(None);
Expand All @@ -46,11 +56,15 @@ pub(crate) async fn resolve_registry_token(config: &Config) -> anyhow::Result<Op
let vault: Box<dyn VaultProvider> = match config.vault.backend {
zeph_config::VaultBackend::Env => Box::new(EnvVaultProvider),
zeph_config::VaultBackend::Age => {
let dir = default_vault_dir();
let provider =
AgeVaultProvider::load_async(&dir.join("vault-key.txt"), &dir.join("secrets.age"))
.await
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let (vault_key_path, vault_secrets_path) = crate::bootstrap::resolve_vault_paths(
config,
vault_override,
vault_key_override,
vault_path_override,
);
let provider = AgeVaultProvider::load_async(&vault_key_path, &vault_secrets_path)
.await
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
Box::new(provider)
}
zeph_config::VaultBackend::Keyring => {
Expand Down Expand Up @@ -154,7 +168,9 @@ mod tests {
config.skills.registry.enabled = false;
config.skills.registry.auth_vault_key = Some("ZEPH_SKILL_REGISTRY_TOKEN".to_owned());

let result = resolve_registry_token(&config).await.unwrap();
let result = resolve_registry_token(&config, None, None, None)
.await
.unwrap();
assert!(result.is_none());
}

Expand All @@ -164,7 +180,9 @@ mod tests {
config.skills.registry.enabled = true;
config.skills.registry.auth_vault_key = None;

let result = resolve_registry_token(&config).await.unwrap();
let result = resolve_registry_token(&config, None, None, None)
.await
.unwrap();
assert!(result.is_none());
}

Expand All @@ -181,7 +199,9 @@ mod tests {
config.skills.registry.auth_vault_key = Some(key.to_owned());
config.vault.backend = zeph_config::VaultBackend::Env;

let result = resolve_registry_token(&config).await.unwrap();
let result = resolve_registry_token(&config, None, None, None)
.await
.unwrap();

#[allow(unsafe_code)]
unsafe {
Expand All @@ -194,6 +214,89 @@ mod tests {
);
}

/// #6591: proves the `Age` backend actually reads from the CLI `--vault-key`/`--vault-path`
/// override, not from `default_vault_dir()`. `XDG_CONFIG_HOME` is pointed at an empty
/// tempdir with no vault files, so a silent fallback to the default dir would surface as an
/// `Err` here rather than resolving the secret — proving the override path was actually
/// used to read it, not merely accepted and ignored.
#[tokio::test]
#[serial_test::serial]
async fn resolve_registry_token_age_backend_uses_vault_override_paths() {
let override_dir = tempfile::tempdir().expect("override tempdir");
let default_dir = tempfile::tempdir().expect("default tempdir");
let key_path = override_dir.path().join("vault-key.txt");
let vault_path = override_dir.path().join("secrets.age");

AgeVaultProvider::init_vault_at(&key_path, &vault_path, false).expect("init vault");
let mut vault = AgeVaultProvider::load_async(&key_path, &vault_path)
.await
.expect("load fresh vault");
vault
.set_secret_mut(
"ZEPH_TEST_REGISTRY_TOKEN_AGE".to_owned(),
"override-token-value".to_owned(),
false,
)
.expect("seed secret");
vault.save_async().await.expect("persist vault");

#[allow(unsafe_code)] // scoped env mutation; #[serial] avoids cross-test races
unsafe {
std::env::set_var("XDG_CONFIG_HOME", default_dir.path());
}

let mut config = Config::default();
config.skills.registry.enabled = true;
config.skills.registry.auth_vault_key = Some("ZEPH_TEST_REGISTRY_TOKEN_AGE".to_owned());
config.vault.backend = zeph_config::VaultBackend::Age;

let result =
resolve_registry_token(&config, None, Some(&key_path), Some(&vault_path)).await;

#[allow(unsafe_code)]
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}

let secret = result
.expect("override vault must load successfully")
.expect("secret must resolve from the override path");
assert_eq!(secret.expose(), "override-token-value");
}

/// Companion sanity check: with no `--vault-key`/`--vault-path` override, resolution must
/// still attempt `default_vault_dir()` (pointed at an empty tempdir via `XDG_CONFIG_HOME`)
/// rather than silently succeeding some other way — a missing vault there must surface as
/// an `Err`, matching pre-#6591 default-path behavior.
#[tokio::test]
#[serial_test::serial]
async fn resolve_registry_token_age_backend_falls_back_to_default_vault_dir_without_override() {
let default_dir = tempfile::tempdir().expect("default tempdir");

#[allow(unsafe_code)]
unsafe {
std::env::set_var("XDG_CONFIG_HOME", default_dir.path());
}

let mut config = Config::default();
config.skills.registry.enabled = true;
config.skills.registry.auth_vault_key = Some("ZEPH_TEST_REGISTRY_TOKEN_AGE".to_owned());
config.vault.backend = zeph_config::VaultBackend::Age;

let result = resolve_registry_token(&config, None, None, None).await;

#[allow(unsafe_code)]
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}

assert!(
result.is_err(),
"no vault at default_vault_dir() must surface as an error, proving the default \
path (not a silent override bypass) was exercised"
);
}

#[test]
fn print_search_results_handles_empty_slice() {
// Pure formatting — assert it doesn't panic on the empty-results path (review
Expand Down
Loading
Loading