Compare commits

..
Author SHA1 Message Date
Claude 47f80ddc22 style: fix formatting
https://claude.ai/code/session_01GG37cPSH8vfi9nriukuorf
2026-03-23 13:12:21 +00:00
ZakiandClaude Opus 4.6 d2098f1030 test(agent): strengthen regression test for missing thread error handling
Replace shallow assertion-only test with one that exercises the actual
match-based error detection pattern used in process_approval()'s
rejection and state-setting paths.

Addresses Gemini review feedback on #1579.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 05:56:20 -07:00
ZakiandClaude Opus 4.6 79a2c5d9dd fix(agent): return errors when approval thread disappears (#1487)
Replace silent if-let-Some patterns with explicit match arms that log
errors and return error responses when threads are not found during
approval processing. Critical state mutations (complete turn, clear
approval, set Processing, await approval) return errors. Auxiliary
operations (record tool result) log errors but continue since the tool
already executed.

Closes #1487

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 23:26:59 -07:00
15 changed files with 179 additions and 1497 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
- group: extensions
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
- group: routines
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
Generated
+12 -12
View File
@@ -157,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2136,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4134,7 +4134,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5472,7 +5472,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6154,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6354,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
@@ -6379,7 +6379,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7179,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8029,7 +8029,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
| `models` | ✅ | 🚧 | P1 | `models list [<provider>]` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set <model>`, `models set-provider <provider> [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
| `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | ✅ | - | System status (enriched session details) |
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
-12
View File
@@ -131,18 +131,6 @@ impl Agent {
}
}
if let Some(extension_manager) = self.deps.extension_manager.as_ref() {
match extension_manager.llm_extension_state_summary().await {
Ok(Some(summary)) => {
reasoning = reasoning.with_extension_state_summary(summary);
}
Ok(None) => {}
Err(e) => {
tracing::debug!("Could not load extension state summary: {}", e);
}
}
}
if let Some(prompt) = system_prompt {
reasoning = reasoning.with_system_prompt(prompt);
}
+153 -31
View File
@@ -992,8 +992,16 @@ impl Agent {
{
// Put it back and return error
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.await_approval(pending);
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.await_approval(pending);
}
None => {
tracing::warn!(
%thread_id,
"Thread disappeared while restoring pending approval after request ID mismatch"
);
}
}
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
@@ -1015,8 +1023,19 @@ impl Agent {
// Reset thread state to processing
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.state = ThreadState::Processing;
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.state = ThreadState::Processing;
}
None => {
tracing::error!(
%thread_id,
"Thread disappeared while setting state to Processing during approval"
);
return Ok(SubmissionResult::error(
"Internal error: thread no longer exists",
));
}
}
}
@@ -1100,13 +1119,21 @@ impl Agent {
// Record sanitized result in thread
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
if is_tool_error {
turn.record_tool_error(result_content.clone());
} else {
turn.record_tool_result(serde_json::json!(result_content));
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
if let Some(turn) = thread.last_turn_mut() {
if is_tool_error {
turn.record_tool_error(result_content.clone());
} else {
turn.record_tool_result(serde_json::json!(result_content));
}
}
}
None => {
tracing::error!(
%thread_id,
"Thread disappeared while recording tool result during approval"
);
}
}
}
@@ -1354,13 +1381,22 @@ impl Agent {
// Record sanitized result in thread
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
if is_deferred_error {
turn.record_tool_error(deferred_content.clone());
} else {
turn.record_tool_result(serde_json::json!(deferred_content));
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
if let Some(turn) = thread.last_turn_mut() {
if is_deferred_error {
turn.record_tool_error(deferred_content.clone());
} else {
turn.record_tool_result(serde_json::json!(deferred_content));
}
}
}
None => {
tracing::error!(
%thread_id,
tool_name = %tc.name,
"Thread disappeared while recording deferred tool result during approval"
);
}
}
}
@@ -1413,8 +1449,19 @@ impl Agent {
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.await_approval(new_pending);
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.await_approval(new_pending);
}
None => {
tracing::error!(
%thread_id,
"Thread disappeared while setting up deferred tool approval"
);
return Ok(SubmissionResult::error(
"Internal error: thread no longer exists",
));
}
}
}
@@ -1546,17 +1593,28 @@ impl Agent {
);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
}
None => {
tracing::error!(
%thread_id,
"Thread disappeared during approval rejection"
);
return Ok(SubmissionResult::error(
"Internal error: thread no longer exists",
));
}
}
}
@@ -2098,6 +2156,70 @@ mod tests {
}
}
#[tokio::test]
async fn test_approval_on_missing_thread_should_error() {
// Regression for #1487: when a thread disappears from the session
// during approval processing, the code must return a visible error
// rather than silently succeeding.
//
// We can't call process_approval() directly (requires full Agent),
// so we simulate the exact code pattern used in the rejection and
// state-setting paths: lock session, match on get_mut, verify the
// None arm produces an error.
use crate::agent::session::{Session, Thread, ThreadState};
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
let thread_id = Uuid::new_v4();
let session_id = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("test-user")));
// Scenario 1: Thread never existed
{
let sess = session.lock().await;
let result = match sess.threads.get(&thread_id) {
Some(_) => Ok("processed"),
None => Err("Internal error: thread no longer exists"),
};
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Internal error: thread no longer exists"
);
}
// Scenario 2: Thread existed then was removed (simulates disappearance
// between lock acquisitions -- the TOCTOU window this fix addresses)
{
let mut sess = session.lock().await;
let mut thread = Thread::with_id(thread_id, session_id);
thread.start_turn("pending approval");
thread.state = ThreadState::AwaitingApproval;
sess.threads.insert(thread_id, thread);
}
{
let mut sess = session.lock().await;
// Simulate thread disappearing (e.g., pruned by another task)
sess.threads.remove(&thread_id);
// The rejection path must detect this and return an error
let result = match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.clear_pending_approval();
thread.complete_turn("rejected");
Ok("rejection persisted")
}
None => Err("Internal error: thread no longer exists"),
};
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"Internal error: thread no longer exists"
);
}
}
#[test]
fn test_queue_cap_rejects_at_capacity() {
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
-10
View File
@@ -25,7 +25,6 @@ pub mod import;
mod logs;
mod mcp;
pub mod memory;
mod models;
pub mod oauth_defaults;
mod pairing;
mod registry;
@@ -46,7 +45,6 @@ pub use logs::{LogsCommand, run_logs_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
pub use memory::run_memory_command_with_db;
pub use models::{ModelsCommand, run_models_command};
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command};
pub use routines::{RoutinesCommand, run_routines_command};
@@ -219,14 +217,6 @@ pub enum Command {
)]
Hooks(HooksCommand),
/// Manage LLM providers and models
#[command(
subcommand,
about = "Manage LLM providers and models",
long_about = "List providers, view current configuration, and set active provider/model.\nExamples:\n ironclaw models list\n ironclaw models list openai --verbose\n ironclaw models status\n ironclaw models set gpt-4o\n ironclaw models set-provider anthropic --model claude-sonnet-4-6-20250514"
)]
Models(ModelsCommand),
/// Probe external dependencies and validate configuration
#[command(
about = "Run diagnostics",
-864
View File
@@ -1,864 +0,0 @@
//! Models management CLI commands.
//!
//! Provides subcommands for listing providers, viewing current model
//! configuration, and setting the active provider/model. Settings are
//! persisted to both `config.toml` and `~/.ironclaw/.env` so changes
//! take effect immediately (no DB connection required).
use clap::Subcommand;
use std::path::Path;
use crate::llm::registry::ProviderRegistry;
use crate::settings::Settings;
#[derive(Subcommand, Debug, Clone)]
pub enum ModelsCommand {
/// List providers (or available models for a specific provider)
List {
/// Show only a specific provider (by ID or alias)
provider: Option<String>,
/// Show detailed information (env vars, base URL, protocol)
#[arg(short, long)]
verbose: bool,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show current model configuration
Status {
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Set the default model
Set {
/// Model name (e.g., "gpt-5-mini", "claude-sonnet-4-6-20250514")
model: String,
},
/// Set the LLM provider
SetProvider {
/// Provider ID or alias (e.g., "openai", "anthropic", "ollama")
provider: String,
/// Also set the model (defaults to provider's default model)
#[arg(long)]
model: Option<String>,
},
}
/// Run the models CLI subcommand.
pub async fn run_models_command(
cmd: ModelsCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
match cmd {
ModelsCommand::List {
provider,
verbose,
json,
} => {
if let Some(ref id) = provider {
cmd_show_provider(id, verbose, json, config_path).await
} else {
cmd_list_providers(verbose, json, config_path).await
}
}
ModelsCommand::Status { json } => cmd_status(json, config_path),
ModelsCommand::Set { model } => cmd_set_model(&model, config_path),
ModelsCommand::SetProvider { provider, model } => {
cmd_set_provider(&provider, model.as_deref(), config_path)
}
}
}
// ─── Shared helpers ───────────────────────────────────────────────
/// Resolve the currently active backend and model from env + settings.
fn resolve_active(config_path: Option<&Path>) -> (String, String) {
let settings = load_settings(config_path);
resolve_active_from_settings(&settings)
}
/// Resolve active backend + model from a pre-loaded Settings.
fn resolve_active_from_settings(settings: &Settings) -> (String, String) {
let backend = std::env::var("LLM_BACKEND")
.ok()
.or_else(|| settings.llm_backend.clone())
.unwrap_or_else(|| "nearai".to_string());
let registry = ProviderRegistry::load();
let canonical_backend = registry
.find(&backend)
.map(|d| d.id.clone())
.unwrap_or_else(|| backend.clone());
let model = if canonical_backend == "nearai" {
std::env::var("NEARAI_MODEL")
.ok()
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| "qwen2.5-72b-instruct:free".to_string())
} else if let Some(def) = registry.find(&canonical_backend) {
std::env::var(&def.model_env)
.ok()
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| def.default_model.clone())
} else {
settings
.selected_model
.clone()
.unwrap_or_else(|| "unknown".to_string())
};
(canonical_backend, model)
}
fn load_settings(config_path: Option<&Path>) -> Settings {
if let Some(path) = config_path {
Settings::load_toml(path).ok().flatten().unwrap_or_default()
} else {
let toml_path = config_toml_path();
if toml_path.exists() {
Settings::load_toml(&toml_path)
.ok()
.flatten()
.unwrap_or_default()
} else {
Settings::load()
}
}
}
fn save_settings(settings: &Settings, config_path: Option<&Path>) -> anyhow::Result<()> {
let path = config_path
.map(|p| p.to_path_buf())
.unwrap_or_else(config_toml_path);
settings
.save_toml(&path)
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(())
}
fn config_toml_path() -> std::path::PathBuf {
crate::bootstrap::ironclaw_base_dir().join("config.toml")
}
/// Try to fetch the live model list from a provider.
///
/// Best-effort: returns `None` if config loading, provider creation, or the
/// `list_models()` call fails (missing API key, network error, etc.).
async fn try_fetch_models(provider_id: &str, config_path: Option<&Path>) -> Option<Vec<String>> {
let config = crate::config::Config::from_env_with_toml(config_path)
.await
.ok()?;
// Override backend to the requested provider so create_llm_provider
// constructs the right one.
let mut llm_config = config.llm.clone();
llm_config.backend = provider_id.to_string();
// For registry providers, resolve the RegistryProviderConfig if not
// already set for this backend.
if provider_id != "nearai" && provider_id != "bedrock" {
let registry = ProviderRegistry::load();
if let Some(def) = registry.find(provider_id)
&& llm_config
.provider
.as_ref()
.is_none_or(|p| p.provider_id != def.id)
{
// Build a minimal RegistryProviderConfig from env + registry
let api_key = def
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok());
if def.api_key_required && api_key.is_none() {
return None;
}
let base_url = def.default_base_url.clone().unwrap_or_default();
llm_config.provider = Some(crate::llm::RegistryProviderConfig {
protocol: def.protocol,
provider_id: def.id.clone(),
model: def.default_model.clone(),
api_key: api_key.map(secrecy::SecretString::from),
base_url,
extra_headers: Vec::new(),
oauth_token: None,
is_codex_chatgpt: false,
refresh_token: None,
auth_path: None,
cache_retention: Default::default(),
unsupported_params: def.unsupported_params.clone(),
});
}
}
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
let provider = crate::llm::create_llm_provider(&llm_config, session)
.await
.ok()?;
provider.list_models().await.ok().filter(|m| !m.is_empty())
}
/// Print available models section (text output).
fn print_model_list(models: &Option<Vec<String>>, active_model: Option<&String>) {
match models {
Some(models) => {
println!("\n Available models ({}):", models.len());
for m in models {
let marker = active_model
.filter(|a| a.as_str() == m)
.map(|_| " (active)")
.unwrap_or("");
println!(" {}{}", m, marker);
}
}
None => {
println!(
"\n Could not fetch model list (missing credentials or provider unavailable)."
);
}
}
}
/// Also update `~/.ironclaw/.env` so changes take effect immediately.
///
/// Skipped when `config_path` is `Some` (custom `--config`), because the user
/// is explicitly targeting a different config file and we must not pollute the
/// default profile's `.env`.
fn sync_to_dotenv(config_path: Option<&Path>, vars: &[(&str, &str)]) {
if config_path.is_some() {
return;
}
if let Err(e) = crate::bootstrap::upsert_bootstrap_vars(vars) {
eprintln!("Warning: failed to update .env: {}", e);
}
}
// ─── status ───────────────────────────────────────────────────────
fn cmd_status(json: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
let settings = load_settings(config_path);
let (backend, model) = resolve_active_from_settings(&settings);
let registry = ProviderRegistry::load();
let fallback = std::env::var("NEARAI_FALLBACK_MODEL").ok();
let cheap = std::env::var("NEARAI_CHEAP_MODEL").ok();
let description = if backend == "nearai" {
"NEAR AI inference (default)".to_string()
} else {
registry
.find(&backend)
.map(|d| d.description.clone())
.unwrap_or_default()
};
if json {
let v = serde_json::json!({
"provider": backend,
"model": model,
"description": description,
"fallback_model": fallback,
"cheap_model": cheap,
});
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
return Ok(());
}
println!("Provider: {} ({})", backend, description);
println!("Model: {}", model);
if let Some(ref fb) = fallback {
println!("Fallback: {}", fb);
}
if let Some(ref ch) = cheap {
println!("Cheap: {}", ch);
}
Ok(())
}
// ─── set ──────────────────────────────────────────────────────────
fn cmd_set_model(model: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
let trimmed = model.trim();
if trimmed.is_empty() {
anyhow::bail!("Model name cannot be empty");
}
let mut settings = load_settings(config_path);
let registry = ProviderRegistry::load();
// Warn if model name doesn't match any known provider's default model
let known_model = registry.all().iter().any(|d| d.default_model == trimmed)
|| trimmed.contains("qwen") // nearai models
|| trimmed.contains("llama")
|| trimmed.contains("gpt")
|| trimmed.contains("claude")
|| trimmed.contains("gemini")
|| trimmed.contains("mistral");
if !known_model {
eprintln!(
"Warning: '{}' is not a recognized model name. Proceeding anyway.",
trimmed
);
}
settings.selected_model = Some(trimmed.to_string());
save_settings(&settings, config_path)?;
let backend = std::env::var("LLM_BACKEND")
.ok()
.or_else(|| settings.llm_backend.clone())
.unwrap_or_else(|| "nearai".to_string());
// Also write to .env so the change takes effect immediately
let model_env = if backend == "nearai" {
"NEARAI_MODEL".to_string()
} else {
registry
.find(&backend)
.map(|d| d.model_env.clone())
.unwrap_or_default()
};
if !model_env.is_empty() {
sync_to_dotenv(config_path, &[(&model_env, trimmed)]);
}
println!("Model set to '{}' (provider: {})", trimmed, backend);
println!(
"Saved to {}",
config_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| config_toml_path().display().to_string())
);
Ok(())
}
// ─── set-provider ─────────────────────────────────────────────────
fn cmd_set_provider(
provider: &str,
model: Option<&str>,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
// Validate and normalize provider
let canonical_id = if provider == "nearai" || provider == "near_ai" || provider == "near" {
"nearai".to_string()
} else {
let def = registry.find(provider).ok_or_else(|| {
let known: Vec<&str> = std::iter::once("nearai")
.chain(registry.all().iter().map(|d| d.id.as_str()))
.collect();
anyhow::anyhow!(
"Unknown provider '{}'. Known providers: {}",
provider,
known.join(", ")
)
})?;
def.id.clone()
};
// Resolve model: explicit > provider default
let resolved_model = if let Some(m) = model {
m.to_string()
} else if canonical_id == "nearai" {
"qwen2.5-72b-instruct:free".to_string()
} else if let Some(def) = registry.find(&canonical_id) {
def.default_model.clone()
} else {
"default".to_string()
};
let mut settings = load_settings(config_path);
settings.llm_backend = Some(canonical_id.clone());
settings.selected_model = Some(resolved_model.clone());
save_settings(&settings, config_path)?;
// Also write to .env so the change takes effect immediately
let model_env = if canonical_id == "nearai" {
"NEARAI_MODEL".to_string()
} else {
registry
.find(&canonical_id)
.map(|d| d.model_env.clone())
.unwrap_or_default()
};
let mut vars: Vec<(&str, &str)> = vec![("LLM_BACKEND", &canonical_id)];
if !model_env.is_empty() {
vars.push((&model_env, &resolved_model));
}
sync_to_dotenv(config_path, &vars);
println!(
"Provider set to '{}', model set to '{}'",
canonical_id, resolved_model
);
println!(
"Saved to {}",
config_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| config_toml_path().display().to_string())
);
Ok(())
}
// ─── list ─────────────────────────────────────────────────────────
/// List all providers with their default models.
async fn cmd_list_providers(
verbose: bool,
json: bool,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
let (active_backend, active_model) = resolve_active(config_path);
if json {
let mut entries: Vec<serde_json::Value> = Vec::new();
// NEAR AI (not in registry)
let nearai_active = active_backend == "nearai";
entries.push(serde_json::json!({
"id": "nearai",
"description": "NEAR AI inference (default)",
"default_model": "qwen2.5-72b-instruct:free",
"active": nearai_active,
"active_model": if nearai_active { Some(&active_model) } else { None },
}));
for def in registry.all() {
let is_active = active_backend == def.id;
let mut v = serde_json::json!({
"id": def.id,
"description": def.description,
"default_model": def.default_model,
"protocol": format!("{:?}", def.protocol),
"active": is_active,
});
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if verbose {
v["aliases"] = serde_json::json!(def.aliases);
v["model_env"] = serde_json::json!(def.model_env);
v["api_key_env"] = serde_json::json!(def.api_key_env);
v["api_key_required"] = serde_json::json!(def.api_key_required);
if let Some(ref url) = def.default_base_url {
v["base_url"] = serde_json::json!(url);
}
if let Some(ref setup) = def.setup {
v["can_list_models"] = serde_json::json!(setup.can_list_models());
}
}
entries.push(v);
}
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
return Ok(());
}
let providers = registry.all();
println!("Active: {} (model: {})\n", active_backend, active_model);
println!(
"{} provider(s) available:\n",
providers.len() + 1 // +1 for NEAR AI
);
// NEAR AI (not in registry)
let nearai_marker = if active_backend == "nearai" { " *" } else { "" };
if verbose {
println!(" nearai{}", nearai_marker);
println!(" Description: NEAR AI inference (default)");
println!(" Default model: qwen2.5-72b-instruct:free");
println!(" Model env: NEARAI_MODEL");
if active_backend == "nearai" {
println!(" Active model: {}", active_model);
}
println!();
} else {
println!(
" {:<22} {:<40} NEAR AI inference (default)",
format!("nearai{nearai_marker}"),
"qwen2.5-72b-instruct:free"
);
}
for def in providers {
let is_active = active_backend == def.id;
let marker = if is_active { " *" } else { "" };
if verbose {
println!(" {}{}", def.id, marker);
println!(" Description: {}", def.description);
println!(" Default model: {}", def.default_model);
println!(" Protocol: {:?}", def.protocol);
println!(" Model env: {}", def.model_env);
if let Some(ref env) = def.api_key_env {
println!(
" API key env: {} ({})",
env,
if def.api_key_required {
"required"
} else {
"optional"
}
);
}
if let Some(ref url) = def.default_base_url {
println!(" Base URL: {}", url);
}
if !def.aliases.is_empty() {
println!(" Aliases: {}", def.aliases.join(", "));
}
if is_active {
println!(" Active model: {}", active_model);
}
println!();
} else {
let model_display = if is_active {
active_model.clone()
} else {
def.default_model.clone()
};
println!(
" {:<22} {:<40} {}",
format!("{}{marker}", def.id),
model_display,
def.description,
);
}
}
if !verbose {
println!();
println!("* = active provider. Use --verbose for details.");
}
Ok(())
}
/// Show details for a specific provider.
async fn cmd_show_provider(
id: &str,
verbose: bool,
json: bool,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
let (active_backend, active_model) = resolve_active(config_path);
// Resolve canonical ID for model fetching
let canonical_id = if id == "nearai" || id == "near_ai" || id == "near" {
"nearai".to_string()
} else {
registry
.find(id)
.map(|d| d.id.clone())
.unwrap_or_else(|| id.to_string())
};
// Try to fetch live model list from the provider
let live_models = try_fetch_models(&canonical_id, config_path).await;
// Check NEAR AI first (not in registry)
if id == "nearai" || id == "near_ai" || id == "near" {
let is_active = active_backend == "nearai";
if json {
let mut v = serde_json::json!({
"id": "nearai",
"description": "NEAR AI inference (default)",
"default_model": "qwen2.5-72b-instruct:free",
"model_env": "NEARAI_MODEL",
"active": is_active,
});
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if let Some(ref models) = live_models {
v["available_models"] = serde_json::json!(models);
}
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
} else {
println!("Provider: nearai");
println!(" Description: NEAR AI inference (default)");
println!(" Default model: qwen2.5-72b-instruct:free");
println!(" Model env: NEARAI_MODEL");
println!(" Active: {}", if is_active { "yes" } else { "no" });
if is_active {
println!(" Active model: {}", active_model);
}
print_model_list(&live_models, is_active.then_some(&active_model));
}
return Ok(());
}
let def = registry.find(id).ok_or_else(|| {
let known: Vec<&str> = std::iter::once("nearai")
.chain(registry.all().iter().map(|d| d.id.as_str()))
.collect();
anyhow::anyhow!(
"Unknown provider '{}'. Known providers: {}",
id,
known.join(", ")
)
})?;
let is_active = active_backend == def.id;
if json {
let mut v = serde_json::json!({
"id": def.id,
"description": def.description,
"protocol": format!("{:?}", def.protocol),
"default_model": def.default_model,
"model_env": def.model_env,
"api_key_env": def.api_key_env,
"api_key_required": def.api_key_required,
"aliases": def.aliases,
"active": is_active,
});
if let Some(ref url) = def.default_base_url {
v["base_url"] = serde_json::json!(url);
}
if let Some(ref setup) = def.setup {
v["can_list_models"] = serde_json::json!(setup.can_list_models());
v["display_name"] = serde_json::json!(setup.display_name());
}
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if verbose && !def.unsupported_params.is_empty() {
v["unsupported_params"] = serde_json::json!(def.unsupported_params);
}
if let Some(ref models) = live_models {
v["available_models"] = serde_json::json!(models);
}
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
return Ok(());
}
println!("Provider: {}", def.id);
println!(" Description: {}", def.description);
println!(" Protocol: {:?}", def.protocol);
println!(" Default model: {}", def.default_model);
println!(" Model env: {}", def.model_env);
if let Some(ref env) = def.api_key_env {
println!(
" API key env: {} ({})",
env,
if def.api_key_required {
"required"
} else {
"optional"
}
);
}
if let Some(ref url) = def.default_base_url {
println!(" Base URL: {}", url);
}
if !def.aliases.is_empty() {
println!(" Aliases: {}", def.aliases.join(", "));
}
if let Some(ref setup) = def.setup {
println!(
" List models: {}",
if setup.can_list_models() {
"supported"
} else {
"not supported"
}
);
println!(" Display name: {}", setup.display_name());
}
if !def.unsupported_params.is_empty() {
println!(" Unsupported: {}", def.unsupported_params.join(", "));
}
println!(" Active: {}", if is_active { "yes" } else { "no" });
if is_active {
println!(" Active model: {}", active_model);
}
print_model_list(&live_models, is_active.then_some(&active_model));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_active_defaults_to_nearai() {
let settings = Settings::default();
assert!(settings.llm_backend.is_none());
assert!(settings.selected_model.is_none());
}
#[test]
fn registry_loads_all_providers() {
let registry = ProviderRegistry::load();
let all = registry.all();
assert!(
all.len() >= 10,
"should have at least 10 built-in providers, got {}",
all.len()
);
}
#[test]
fn registry_find_by_alias() {
let registry = ProviderRegistry::load();
let def = registry
.find("claude")
.expect("claude alias should resolve");
assert_eq!(def.id, "anthropic");
}
#[test]
fn all_providers_have_description() {
let registry = ProviderRegistry::load();
for def in registry.all() {
assert!(
!def.description.is_empty(),
"provider {} should have a description",
def.id
);
}
}
#[test]
fn set_model_persists_to_toml() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_model("gpt-5-mini", Some(&toml_path)).expect("set model");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.selected_model.as_deref(), Some("gpt-5-mini"));
}
#[test]
fn set_provider_validates_unknown() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
let result = cmd_set_provider("nonexistent_provider", None, Some(&toml_path));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unknown provider"),
"should mention unknown provider: {}",
err
);
}
#[test]
fn set_provider_persists_to_toml() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
assert_eq!(
settings.selected_model.as_deref(),
Some("llama-3.3-70b-versatile")
);
}
#[test]
fn set_provider_with_custom_model() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("anthropic", Some("claude-opus-4-6"), Some(&toml_path))
.expect("set provider with model");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(settings.selected_model.as_deref(), Some("claude-opus-4-6"));
}
#[test]
fn custom_config_does_not_pollute_default_dotenv() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
// With a custom config path, sync_to_dotenv should be a no-op
// (it returns early when config_path is Some).
// We verify by checking that cmd_set_provider succeeds without
// trying to write to the default ~/.ironclaw/.env.
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider with custom config");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
// The key assertion is that no error was thrown trying to write
// to the default .env — sync_to_dotenv skipped it.
}
#[test]
fn set_model_rejects_empty_name() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
let result = cmd_set_model("", Some(&toml_path));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("cannot be empty"),
"should reject empty model name"
);
let result2 = cmd_set_model(" ", Some(&toml_path));
assert!(result2.is_err());
}
#[test]
fn set_provider_normalizes_alias() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("claude", None, Some(&toml_path)).expect("set via alias");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(
settings.llm_backend.as_deref(),
Some("anthropic"),
"alias should be normalized to canonical ID"
);
}
}
@@ -20,7 +20,6 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -20,7 +20,6 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -23,7 +23,6 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -23,7 +23,6 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
-216
View File
@@ -1498,52 +1498,6 @@ impl ExtensionManager {
Ok(extensions)
}
/// Build a compact, deterministic extension snapshot for LLM prompt context.
pub async fn llm_extension_state_summary(&self) -> Result<Option<String>, ExtensionError> {
let mut extensions = self.list(None, false).await?;
extensions.sort_by(|a, b| {
llm_extension_sort_key(a.kind)
.cmp(&llm_extension_sort_key(b.kind))
.then_with(|| a.name.cmp(&b.name))
});
let mut channels = Vec::new();
let mut tools = Vec::new();
let mut servers = Vec::new();
for extension in extensions {
let owner_bound = matches!(extension.kind, ExtensionKind::WasmChannel)
&& self.has_wasm_channel_owner_binding(&extension.name).await;
if !(extension.active || extension.authenticated || owner_bound) {
continue;
}
let item = llm_extension_summary_item(&extension, owner_bound);
match extension.kind {
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => channels.push(item),
ExtensionKind::WasmTool => tools.push(item),
ExtensionKind::McpServer => servers.push(item),
}
}
let mut lines = Vec::new();
if !channels.is_empty() {
lines.push(format!("- Channels: {}", channels.join("; ")));
}
if !tools.is_empty() {
lines.push(format!("- Tools: {}", tools.join("; ")));
}
if !servers.is_empty() {
lines.push(format!("- MCP servers: {}", servers.join("; ")));
}
if lines.is_empty() {
Ok(None)
} else {
Ok(Some(lines.join("\n")))
}
}
/// Remove an installed extension.
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
Self::validate_extension_name(name)?;
@@ -5659,40 +5613,6 @@ fn combine_install_errors(
}
}
fn llm_extension_sort_key(kind: ExtensionKind) -> u8 {
match kind {
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => 0,
ExtensionKind::WasmTool => 1,
ExtensionKind::McpServer => 2,
}
}
fn llm_extension_summary_item(extension: &InstalledExtension, owner_bound: bool) -> String {
let mut states = Vec::new();
if extension.authenticated {
states.push("authenticated".to_string());
}
if extension.active {
states.push("active".to_string());
} else if extension.authenticated {
states.push("inactive".to_string());
}
if owner_bound {
states.push("owner-bound".to_string());
}
if !extension.tools.is_empty() {
let mut tool_names = extension.tools.clone();
tool_names.sort();
states.push(format!("tools: {}", tool_names.join(", ")));
}
if states.is_empty() {
extension.name.clone()
} else {
format!("{} ({})", extension.name, states.join(", "))
}
}
#[cfg(test)]
mod tests {
use std::fmt::Debug;
@@ -6651,142 +6571,6 @@ mod tests {
)
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_llm_extension_state_summary_reports_active_owner_bound_telegram()
-> Result<(), String> {
let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?;
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?;
std::fs::write(channels_dir.join("telegram.wasm"), b"mock")
.map_err(|err| format!("write wasm: {err}"))?;
std::fs::write(
channels_dir.join("telegram.capabilities.json"),
serde_json::to_vec(&serde_json::json!({
"type": "channel",
"name": "telegram",
"setup": {
"required_secrets": [
{
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
"optional": false
}
]
},
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/telegram"]
}
},
"config": {
"owner_id": null
}
}))
.map_err(|err| format!("serialize capabilities: {err}"))?,
)
.map_err(|err| format!("write capabilities: {err}"))?;
let (db, _db_tmp) = crate::testing::test_db().await;
let manager = {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::testing::credentials::TEST_CRYPTO_KEY;
use crate::tools::ToolRegistry;
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager;
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
let crypto = Arc::new(
SecretsCrypto::new(master_key)
.map_err(|err| format!("failed to construct test crypto: {err}"))?,
);
ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
None,
dir.path().join("tools"),
channels_dir.clone(),
None,
"test".to_string(),
Some(db),
Vec::new(),
)
};
let channel_manager = Arc::new(ChannelManager::new());
let runtime = Arc::new(
WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing())
.map_err(|err| format!("runtime: {err}"))?,
);
let pairing_store = Arc::new(PairingStore::with_base_dir(
dir.path().join("pairing-state"),
));
let router = Arc::new(WasmChannelRouter::new());
manager
.set_channel_runtime(
Arc::clone(&channel_manager),
Arc::clone(&runtime),
Arc::clone(&pairing_store),
Arc::clone(&router),
std::collections::HashMap::new(),
)
.await;
manager
.set_test_wasm_channel_loader(Arc::new({
let runtime = Arc::clone(&runtime);
let pairing_store = Arc::clone(&pairing_store);
move |name| {
Ok(make_test_loaded_channel(
Arc::clone(&runtime),
name,
Arc::clone(&pairing_store),
))
}
}))
.await;
manager
.set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| {
if existing_owner_id.is_some() {
return Err(ExtensionError::Other(
"owner binding should be derived during setup".to_string(),
));
}
Ok(TelegramBindingResult::Bound(TelegramBindingData {
owner_id: 424242,
bot_username: Some("test_hot_bot".to_string()),
binding_state: TelegramOwnerBindingState::VerifiedNow,
}))
}))
.await;
manager
.configure(
"telegram",
&std::collections::HashMap::from([(
"telegram_bot_token".to_string(),
"123456789:ABCdefGhI".to_string(),
)]),
&std::collections::HashMap::new(),
)
.await
.map_err(|err| format!("configure succeeds: {err}"))?;
let summary = manager
.llm_extension_state_summary()
.await
.map_err(|err| format!("summary: {err}"))?
.ok_or_else(|| "expected extension summary".to_string())?;
require(
summary.contains("- Channels: telegram (authenticated, active, owner-bound)"),
format!("unexpected summary: {summary}"),
)
}
#[tokio::test]
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
-> Result<(), String> {
+12 -92
View File
@@ -353,8 +353,6 @@ pub struct Reasoning {
workspace_system_prompt: Option<String>,
/// Optional skill context block to inject into system prompt.
skill_context: Option<String>,
/// Optional snapshot of connected/active extensions for the current user.
extension_state_summary: Option<String>,
/// Channel name (e.g. "discord", "telegram") for formatting hints.
channel: Option<String>,
/// Model name for runtime context.
@@ -373,7 +371,6 @@ impl Reasoning {
llm,
workspace_system_prompt: None,
skill_context: None,
extension_state_summary: None,
channel: None,
model_name: None,
is_group_chat: false,
@@ -403,14 +400,6 @@ impl Reasoning {
self
}
/// Set extension runtime context to inject into the system prompt.
pub fn with_extension_state_summary(mut self, summary: String) -> Self {
if !summary.is_empty() {
self.extension_state_summary = Some(summary);
}
self
}
/// Set the channel name for channel-specific formatting hints.
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
let ch = channel.into();
@@ -943,54 +932,21 @@ Example:
}
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
let has_search = tools.iter().any(|t| t.name == "tool_search");
let has_list = tools.iter().any(|t| t.name == "tool_list");
let has_info = tools.iter().any(|t| t.name == "extension_info");
if self.extension_state_summary.is_none() && !has_search && !has_list && !has_info {
// Only include when the extension management tools are available
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
if !has_ext_tools {
return String::new();
}
let mut blocks = Vec::new();
if let Some(ref summary) = self.extension_state_summary {
blocks.push(format!(
"Current extension state for this user:\n{}",
summary
));
}
if has_search || has_list || has_info {
let mut guidance = String::from(
"You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Tools** — sandboxed functions that extend your abilities.\n\
- **MCP servers** — external API integrations via the Model Context Protocol.",
);
if has_list {
guidance.push_str(
"\n\nBefore telling the user to connect, activate, or re-enable an extension, \
inspect the current state with `tool_list`.",
);
}
if has_info {
guidance.push_str(
"\nUse `extension_info` when you need deeper compatibility or runtime details \
for an installed extension.",
);
}
if has_search {
guidance.push_str(
"\nUse `tool_search` to find extensions by name. Refer to them by their kind \
(channel, tool, or server) — not as \"MCP server\" generically.",
);
}
blocks.push(guidance);
}
format!("\n\n## Extensions\n{}", blocks.join("\n\n"))
"\n\n## Extensions\n\
You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Tools** — sandboxed functions that extend your abilities.\n\
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
Use `tool_search` to find extensions by name. Refer to them by their kind \
(channel, tool, or server) — not as \"MCP server\" generically."
.to_string()
}
fn build_channel_section(&self) -> String {
@@ -2329,42 +2285,6 @@ That's my plan."#;
);
}
#[test]
fn test_system_prompt_includes_extension_runtime_summary() {
let reasoning = make_test_reasoning().with_extension_state_summary(
"- Channels: telegram (authenticated, active, owner-bound)".to_string(),
);
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(
prompt.contains("## Extensions"),
"Prompt should contain an Extensions section when runtime state is present"
);
assert!(
prompt.contains("telegram (authenticated, active, owner-bound)"),
"Prompt should include the injected extension runtime summary"
);
}
#[test]
fn test_system_prompt_extension_guidance_prefers_inspection_before_reconnect() {
let reasoning = make_test_reasoning();
let prompt = reasoning.build_system_prompt_with_tools(&make_tools(&[
"tool_search",
"tool_list",
"extension_info",
]));
assert!(
prompt.contains("inspect the current state with `tool_list`"),
"Prompt should direct the model to inspect installed/active state first"
);
assert!(
prompt.contains("Use `extension_info` when you need deeper compatibility"),
"Prompt should mention extension_info for deeper extension details"
);
}
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
#[test]
-5
View File
@@ -142,11 +142,6 @@ async fn async_main() -> anyhow::Result<()> {
init_cli_tracing();
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
}
Some(Command::Models(models_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_models_command(models_cmd.clone(), cli.config.as_deref())
.await;
}
Some(Command::Doctor) => {
init_cli_tracing();
return ironclaw::cli::run_doctor_command().await;
@@ -1,249 +0,0 @@
"""OAuth URL parameter validation e2e tests.
Tests for bug #992: Google OAuth URL broken when initiated from Telegram.
Specifically verifies that OAuth query parameters are correctly formatted:
- "client_id" (with underscore) NOT "clientid" (without underscore)
- All standard OAuth parameters are present and correctly encoded
- URLs are consistent across channels (web, Telegram, etc.)
The test verifies:
1. OAuth URL is generated with correct parameters
2. URL works with the OAuth provider (Google)
3. Extra parameters (access_type, prompt) are preserved
"""
from urllib.parse import parse_qs, urlparse
import pytest
from helpers import api_post, api_get
async def _extract_oauth_params(auth_url: str) -> dict:
"""Extract and validate OAuth query parameters from auth_url.
Returns dict with parsed parameters:
{
'client_id': '...',
'redirect_uri': '...',
'response_type': 'code',
'scope': '...',
'state': '...',
'access_type': '...',
'prompt': '...',
...
}
"""
parsed = urlparse(auth_url)
qs = parse_qs(parsed.query)
# Convert lists to single values for easier testing
params = {k: v[0] if len(v) > 0 else v for k, v in qs.items()}
return params
async def _get_extension(ironclaw_server, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(ironclaw_server, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
@pytest.fixture
async def installed_gmail(ironclaw_server):
"""Installs the 'gmail' extension before a test and removes it after.
This fixture handles the setup and teardown of the Gmail extension,
ensuring a clean state for each test.
"""
# Ensure Gmail is not installed before test
ext = await _get_extension(ironclaw_server, "gmail")
if ext:
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
assert r.status_code == 200
# Install Gmail
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "gmail"},
timeout=180,
)
assert r.status_code == 200, f"Gmail install failed: {r.text}"
assert r.json().get("success") is True, f"Install failed: {r.json().get('message', '')}"
yield
# Teardown: remove gmail
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
assert r.status_code == 200, f"Gmail removal failed: {r.text}"
@pytest.fixture
async def auth_url(ironclaw_server, installed_gmail):
"""Generate and return an OAuth auth URL.
Requires Gmail to be installed (depends on installed_gmail fixture).
"""
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Setup failed: {data.get('message', '')}"
url = data.get("auth_url")
assert url is not None, f"Expected auth_url in response: {data}"
assert "accounts.google.com" in url, f"auth_url should point to Google: {url}"
return url
@pytest.fixture
async def oauth_params(auth_url):
"""Extract and return OAuth parameters from auth_url.
Depends on auth_url fixture.
"""
return await _extract_oauth_params(auth_url)
# ─ OAuth URL parameter validation tests ────────────────────────────────
async def test_oauth_url_has_client_id_not_clientid(oauth_params, auth_url):
"""Verify OAuth URL has 'client_id' (with underscore), NOT 'clientid'.
Bug #992: Ensure the parameter name is correct across all channels.
"""
params = oauth_params
# The bug: "clientid" appears instead of "client_id"
# Verify the CORRECT parameter name exists
assert "client_id" in params, (
f"OAuth URL missing 'client_id' parameter. "
f"URL: {auth_url}\nParams: {params}"
)
assert params["client_id"], "client_id should have a value"
# Verify the INCORRECT parameter name does NOT exist
assert "clientid" not in params, (
f"OAuth URL should NOT have 'clientid' (without underscore). "
f"Bug #992: URL: {auth_url}\nParams: {params}"
)
async def test_oauth_url_has_required_parameters(oauth_params):
"""Verify all required OAuth 2.0 parameters are present."""
params = oauth_params
# Required OAuth 2.0 parameters
required = ["client_id", "response_type", "redirect_uri", "scope", "state"]
for param in required:
assert param in params, (
f"Missing required OAuth parameter: {param}. "
f"Params: {params}"
)
assert params[param], f"Parameter '{param}' should have a non-empty value"
# Validate specific values
assert params["response_type"] == "code", "Should use authorization_code flow"
assert "oauth" in params["redirect_uri"], "Redirect URI should be an OAuth callback"
async def test_oauth_url_has_extra_params(oauth_params):
"""Verify extra_params from capabilities.json are included."""
params = oauth_params
# Google-specific extra_params from gmail-tool.capabilities.json
assert "access_type" in params, (
"Should include 'access_type' from extra_params"
)
assert params["access_type"] == "offline", (
"access_type should be 'offline' for Gmail"
)
assert "prompt" in params, (
"Should include 'prompt' from extra_params"
)
assert params["prompt"] == "consent", (
"prompt should be 'consent' for Gmail"
)
async def test_oauth_url_is_valid_google_oauth(auth_url):
"""Verify the URL is a valid Google OAuth 2.0 authorization URL."""
# Verify scheme and host
parsed = urlparse(auth_url)
assert parsed.scheme == "https", "OAuth URL must use HTTPS"
assert "accounts.google.com" in parsed.netloc, "Must be Google's OAuth endpoint"
assert parsed.path == "/o/oauth2/v2/auth", "Must use Google OAuth 2.0 endpoint"
async def test_oauth_url_state_is_unique(ironclaw_server, installed_gmail, oauth_params, auth_url):
"""Verify CSRF state is present and unique per request."""
# Get a new OAuth URL
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
new_auth_url = r.json().get("auth_url")
assert new_auth_url is not None
# Extract state from both URLs
original_params = oauth_params
new_params = await _extract_oauth_params(new_auth_url)
original_state = original_params.get("state")
new_state = new_params.get("state")
assert original_state is not None, "Should have state parameter"
assert new_state is not None, "New request should have state parameter"
assert original_state != new_state, (
"CSRF state should be unique per request (for security)"
)
async def test_oauth_url_escaping(auth_url):
"""Verify URL query parameters are properly escaped."""
# Verify special characters in values are URL-encoded
# For example, scopes contain spaces which should be %20
assert "%20" in auth_url or "+" in auth_url or "%2B" in auth_url or " " not in auth_url, (
"OAuth URL should properly encode special characters in parameters"
)
# ─ Telegram-specific tests (when Telegram channel is available) ──────────
class TestOAuthURLViaTelegram:
"""Test OAuth URL generation specifically via Telegram channel.
These tests would verify that the same OAuth URL works correctly when
transmitted through the Telegram WASM channel (as opposed to web gateway).
Currently marked as xfail pending Telegram channel setup in E2E tests.
"""
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
async def test_telegram_oauth_url_has_correct_parameters(self):
"""Verify OAuth URL sent via Telegram has correct parameter names."""
# This test would:
# 1. Send a message via Telegram that triggers OAuth
# 2. Capture the status update sent to Telegram
# 3. Extract the auth_url from the message
# 4. Verify it has "client_id" not "clientid"
pass
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
async def test_telegram_oauth_url_can_be_regenerated(self):
"""Verify OAuth URL can be regenerated when requested via Telegram."""
# This test would verify that the bug #992 symptom
# "URL cannot be regenerated when asked" is fixed.
# If the URL is cached incorrectly, regeneration would fail.
pass