fix: batch of quick fixes (#417, #338, #330, #358, #419, #344) (#428)

- #417: Add Docker auto-start login item hint for macOS in setup wizard
- #338: Add clippy.toml with complexity thresholds for AI-assisted dev
- #330: Add structured FallbackFailed error variant to ExtensionError
- #358: Revoke credential mappings on extension removal (SharedCredentialRegistry)
- #419: Detect conflicting cloudflared services during tunnel setup
- #344: Improve embedding auth failure warning with configuration hint

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-03-01 09:01:07 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fa52df593d
commit 7481aea083
8 changed files with 255 additions and 21 deletions
+8
View File
@@ -0,0 +1,8 @@
# Complexity guardrails for AI-assisted development quality.
# These thresholds prevent new violations while preserving existing code.
# See: https://github.com/nearai/ironclaw/issues/338
cognitive-complexity-threshold = 15 # default: 25 (only active when lint is enabled)
too-many-lines-threshold = 100 # default: 100 (only active when lint is enabled)
too-many-arguments-threshold = 7 # default: 7 (keep default, avoids new violations)
type-complexity-threshold = 250 # default: 250 (keep default, avoids new violations)
+68 -16
View File
@@ -544,6 +544,12 @@ impl ExtensionManager {
// Unregister from tool registry
self.tool_registry.unregister(name).await;
// Revoke credential mappings from the shared registry
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
self.revoke_credential_mappings(&cap_path).await;
// Unregister hooks registered from this plugin source.
let removed_hooks = self
.unregister_hook_prefix(&format!("plugin.tool:{}::", name))
@@ -561,9 +567,6 @@ impl ExtensionManager {
// Delete files
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
if wasm_path.exists() {
tokio::fs::remove_file(&wasm_path)
@@ -587,6 +590,9 @@ impl ExtensionManager {
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
// Revoke credential mappings before deleting the capabilities file
self.revoke_credential_mappings(&cap_path).await;
if wasm_path.exists() {
tokio::fs::remove_file(&wasm_path)
.await
@@ -671,16 +677,17 @@ impl ExtensionManager {
primary_error = %primary_err,
"Primary install failed, trying fallback source"
);
self.try_install_from_source(entry, fallback)
.await
.map_err(|fallback_err| {
match self.try_install_from_source(entry, fallback).await {
Ok(result) => Ok(result),
Err(fallback_err) => {
tracing::error!(
extension = %entry.name,
fallback_error = %fallback_err,
"Fallback install also failed"
);
combine_install_errors(&primary_err, fallback_err)
})
Err(combine_install_errors(primary_err, fallback_err))
}
}
}
}
}
@@ -2538,6 +2545,47 @@ impl ExtensionManager {
}
}
/// Read a capabilities.json file and revoke its credential mappings from
/// the shared credential registry, so removed extensions lose injection
/// authority immediately.
async fn revoke_credential_mappings(&self, cap_path: &std::path::Path) {
if !cap_path.exists() {
return;
}
let Ok(bytes) = tokio::fs::read(cap_path).await else {
return;
};
// Extract secret names from the capabilities JSON.
// Structure: { "http": { "credentials": { "<key>": { "secret_name": "..." } } } }
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return;
};
let secret_names: Vec<String> = json
.get("http")
.and_then(|h| h.get("credentials"))
.and_then(|c| c.as_object())
.map(|creds| {
creds
.values()
.filter_map(|v| v.get("secret_name").and_then(|s| s.as_str()))
.map(String::from)
.collect()
})
.unwrap_or_default();
if secret_names.is_empty() {
return;
}
if let Some(cr) = self.tool_registry.credential_registry() {
cr.remove_mappings_for_secrets(&secret_names);
tracing::info!(
secrets = ?secret_names,
"Revoked credential mappings for removed extension"
);
}
}
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
let Some(ref hooks) = self.hooks else {
return 0;
@@ -2640,18 +2688,18 @@ fn fallback_decision(
/// Combine primary and fallback errors into a single error.
///
/// Preserves `AlreadyInstalled` from the fallback directly; otherwise wraps
/// both error messages into `ExtensionError::Other`.
/// both errors into the structured `ExtensionError::FallbackFailed` variant.
fn combine_install_errors(
primary_err: &ExtensionError,
primary_err: ExtensionError,
fallback_err: ExtensionError,
) -> ExtensionError {
if matches!(fallback_err, ExtensionError::AlreadyInstalled(_)) {
return fallback_err;
}
ExtensionError::Other(format!(
"Primary install failed: {}; fallback install also failed: {}",
primary_err, fallback_err
))
ExtensionError::FallbackFailed {
primary: Box::new(primary_err),
fallback: Box::new(fallback_err),
}
}
#[cfg(test)]
@@ -2748,7 +2796,11 @@ mod tests {
fn test_combine_errors_includes_both_messages() {
let primary = ExtensionError::DownloadFailed("404 Not Found".to_string());
let fallback = ExtensionError::InstallFailed("cargo not found".to_string());
let combined = combine_install_errors(&primary, fallback);
let combined = combine_install_errors(primary, fallback);
assert!(
matches!(combined, ExtensionError::FallbackFailed { .. }),
"Expected FallbackFailed, got: {combined:?}"
);
let msg = combined.to_string();
assert!(msg.contains("404 Not Found"), "missing primary: {msg}");
assert!(msg.contains("cargo not found"), "missing fallback: {msg}");
@@ -2758,7 +2810,7 @@ mod tests {
fn test_combine_errors_forwards_already_installed_from_fallback() {
let primary = ExtensionError::DownloadFailed("404".to_string());
let fallback = ExtensionError::AlreadyInstalled("test".to_string());
let combined = combine_install_errors(&primary, fallback);
let combined = combine_install_errors(primary, fallback);
assert!(
matches!(combined, ExtensionError::AlreadyInstalled(ref name) if name == "test"),
"Expected AlreadyInstalled, got: {combined:?}"
+6
View File
@@ -244,6 +244,12 @@ pub enum ExtensionError {
#[error("Config error: {0}")]
Config(String),
#[error("Primary install failed: {primary}; fallback install also failed: {fallback}")]
FallbackFailed {
primary: Box<ExtensionError>,
fallback: Box<ExtensionError>,
},
#[error("{0}")]
Other(String),
}
+3 -1
View File
@@ -85,7 +85,9 @@ impl Platform {
/// Instructions to start the Docker daemon on this platform.
pub fn start_hint(&self) -> &'static str {
match self {
Platform::MacOS => "Start Docker Desktop from Applications, or run: open -a Docker",
Platform::MacOS => {
"Start Docker Desktop from Applications, or run: open -a Docker\n\n To auto-start at login: System Settings > General > Login Items > add Docker.app"
}
Platform::Linux => "Start the Docker daemon: sudo systemctl start docker",
Platform::Windows => "Start Docker Desktop from the Start menu",
}
+103 -2
View File
@@ -20,8 +20,8 @@ use crate::secrets::SecretsCrypto;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::settings::{Settings, TunnelSettings};
use crate::setup::prompts::{
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
select_one,
confirm, input, optional_input, print_error, print_info, print_success, print_warning,
secret_input, select_one,
};
/// Typed errors for channel setup flows.
@@ -38,6 +38,9 @@ pub enum ChannelSetupError {
#[error("{0}")]
Validation(String),
#[error("Setup cancelled by user")]
Cancelled,
}
/// Context for saving secrets during setup.
@@ -490,6 +493,15 @@ fn setup_tunnel_cloudflare() -> Result<TunnelSettings, ChannelSetupError> {
}
}
// Detect existing cloudflared services that may conflict
if let Some(warning) = detect_existing_cloudflared() {
print_warning(&warning);
if !confirm("Continue anyway?", true)? {
return Err(ChannelSetupError::Cancelled);
}
println!();
}
print_info("Get your tunnel token from the Cloudflare Zero Trust dashboard:");
print_info(" https://one.dash.cloudflare.com/ > Networks > Tunnels");
println!();
@@ -529,6 +541,95 @@ fn setup_tunnel_cloudflare() -> Result<TunnelSettings, ChannelSetupError> {
})
}
/// Detect running cloudflared processes or managed services that could conflict
/// with IronClaw's tunnel management.
fn detect_existing_cloudflared() -> Option<String> {
let mut conflicts = Vec::new();
// Check for running cloudflared processes (all platforms)
#[cfg(unix)]
{
let output = std::process::Command::new("pgrep")
.args(["-x", "cloudflared"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
if let Ok(out) = output
&& out.status.success()
{
let pids = String::from_utf8_lossy(&out.stdout);
let pids: Vec<&str> = pids.trim().lines().collect();
if !pids.is_empty() {
conflicts.push(format!(
"Running cloudflared process(es): PID {}",
pids.join(", ")
));
}
}
}
// macOS: check brew services
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("brew")
.args(["services", "list"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("cloudflared") && line.contains("started") {
conflicts.push("Homebrew service: cloudflared (started)".to_string());
break;
}
}
}
let output = std::process::Command::new("launchctl")
.args(["list"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("cloudflared") {
conflicts.push("launchd service: cloudflared detected".to_string());
break;
}
}
}
}
// Linux: check systemd
#[cfg(target_os = "linux")]
{
let output = std::process::Command::new("systemctl")
.args(["is-active", "cloudflared"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim() == "active" {
conflicts.push("systemd service: cloudflared (active)".to_string());
}
}
}
if conflicts.is_empty() {
None
} else {
Some(format!(
"Detected existing cloudflared service(s) that may conflict:\n {}\n\
Consider stopping them first (e.g., `brew services stop cloudflared` or \
`sudo systemctl stop cloudflared`).",
conflicts.join("\n ")
))
}
}
fn setup_tunnel_tailscale() -> Result<TunnelSettings, ChannelSetupError> {
let funnel = confirm("Use Tailscale Funnel (public internet)?", true)?;
let hostname = optional_input("Hostname override", Some("leave empty for auto-detect"))?;
+9
View File
@@ -311,6 +311,15 @@ pub fn print_error(message: &str) {
eprintln!(" {}", message);
}
/// Print a warning message with yellow exclamation.
pub fn print_warning(message: &str) {
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Yellow));
print!("!");
let _ = execute!(stdout, ResetColor);
println!(" {}", message);
}
/// Print an info message with blue info icon.
pub fn print_info(message: &str) {
let mut stdout = io::stdout();
+48 -1
View File
@@ -77,7 +77,7 @@ impl SharedCredentialRegistry {
}
}
/// Add credential mappings (called when WASM tools register).
/// Add credential mappings tagged with an extension name (called when WASM tools register).
pub fn add_mappings(&self, mappings: impl IntoIterator<Item = CredentialMapping>) {
match self.mappings.write() {
Ok(mut guard) => {
@@ -93,6 +93,23 @@ impl SharedCredentialRegistry {
}
}
/// Remove all credential mappings whose `secret_name` matches any of the given names.
///
/// Called when an extension is unregistered/deactivated so its credential
/// injection authority does not outlive the extension.
pub fn remove_mappings_for_secrets(&self, secret_names: &[String]) {
let mut guard = match self.mappings.write() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!(
"SharedCredentialRegistry RwLock poisoned during remove_mappings_for_secrets; recovering"
);
poisoned.into_inner()
}
};
guard.retain(|m| !secret_names.contains(&m.secret_name));
}
/// Check if any credential mapping matches this host (sync, for requires_approval).
pub fn has_credentials_for_host(&self, host: &str) -> bool {
let guard = match self.mappings.read() {
@@ -564,6 +581,36 @@ mod tests {
assert_eq!(found.len(), 2);
}
#[test]
fn test_shared_registry_remove_mappings_for_secrets() {
let registry = SharedCredentialRegistry::new();
registry.add_mappings(vec![
CredentialMapping::bearer("openai_key", "api.openai.com"),
CredentialMapping::bearer("gh_token", "*.github.com"),
CredentialMapping::header("openai_org", "OpenAI-Organization", "api.openai.com"),
]);
assert_eq!(registry.find_for_host("api.openai.com").len(), 2);
assert!(registry.has_credentials_for_host("api.github.com"));
// Remove only mappings for openai secrets
registry.remove_mappings_for_secrets(&["openai_key".to_string(), "openai_org".to_string()]);
// OpenAI mappings should be gone
assert!(registry.find_for_host("api.openai.com").is_empty());
// GitHub mapping should remain
assert!(registry.has_credentials_for_host("api.github.com"));
}
#[test]
fn test_shared_registry_remove_nonexistent_is_noop() {
let registry = SharedCredentialRegistry::new();
registry.add_mappings(vec![CredentialMapping::bearer("key1", "api.example.com")]);
registry.remove_mappings_for_secrets(&["nonexistent".to_string()]);
assert_eq!(registry.find_for_host("api.example.com").len(), 1);
}
#[test]
fn test_shared_registry_thread_safety() {
use std::sync::Arc;
+10 -1
View File
@@ -813,7 +813,16 @@ impl Workspace {
count += 1;
}
Err(e) => {
tracing::warn!("Failed to embed chunk {}: {}", chunk.id, e);
tracing::warn!(
"Failed to embed chunk {}: {}{}",
chunk.id,
e,
if matches!(e, embeddings::EmbeddingError::AuthFailed) {
". Check OPENAI_API_KEY or set EMBEDDING_PROVIDER=ollama for local embeddings"
} else {
""
}
);
}
}
}