mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f80ddc22 | ||
|
|
d2098f1030 | ||
|
|
79a2c5d9dd |
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- group: features
|
- group: features
|
||||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
||||||
- group: extensions
|
- 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
|
- group: routines
|
||||||
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
Generated
+12
-12
@@ -157,7 +157,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4134,7 +4134,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5472,7 +5472,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6154,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6354,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.45"
|
version = "0.4.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6379,7 +6379,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7179,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8029,7 +8029,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||||
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
| `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) |
|
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||||
|
|||||||
@@ -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 {
|
if let Some(prompt) = system_prompt {
|
||||||
reasoning = reasoning.with_system_prompt(prompt);
|
reasoning = reasoning.with_system_prompt(prompt);
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-31
@@ -992,8 +992,16 @@ impl Agent {
|
|||||||
{
|
{
|
||||||
// Put it back and return error
|
// Put it back and return error
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.await_approval(pending);
|
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(
|
return Ok(SubmissionResult::error(
|
||||||
"Request ID mismatch. Use the correct request ID.",
|
"Request ID mismatch. Use the correct request ID.",
|
||||||
@@ -1015,8 +1023,19 @@ impl Agent {
|
|||||||
// Reset thread state to processing
|
// Reset thread state to processing
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.state = ThreadState::Processing;
|
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
|
// Record sanitized result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
match sess.threads.get_mut(&thread_id) {
|
||||||
&& let Some(turn) = thread.last_turn_mut()
|
Some(thread) => {
|
||||||
{
|
if let Some(turn) = thread.last_turn_mut() {
|
||||||
if is_tool_error {
|
if is_tool_error {
|
||||||
turn.record_tool_error(result_content.clone());
|
turn.record_tool_error(result_content.clone());
|
||||||
} else {
|
} else {
|
||||||
turn.record_tool_result(serde_json::json!(result_content));
|
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
|
// Record sanitized result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
match sess.threads.get_mut(&thread_id) {
|
||||||
&& let Some(turn) = thread.last_turn_mut()
|
Some(thread) => {
|
||||||
{
|
if let Some(turn) = thread.last_turn_mut() {
|
||||||
if is_deferred_error {
|
if is_deferred_error {
|
||||||
turn.record_tool_error(deferred_content.clone());
|
turn.record_tool_error(deferred_content.clone());
|
||||||
} else {
|
} else {
|
||||||
turn.record_tool_result(serde_json::json!(deferred_content));
|
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;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.await_approval(new_pending);
|
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;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
match sess.threads.get_mut(&thread_id) {
|
||||||
thread.clear_pending_approval();
|
Some(thread) => {
|
||||||
thread.complete_turn(&rejection);
|
thread.clear_pending_approval();
|
||||||
// User message already persisted at turn start; save rejection response
|
thread.complete_turn(&rejection);
|
||||||
self.persist_assistant_response(
|
// User message already persisted at turn start; save rejection response
|
||||||
thread_id,
|
self.persist_assistant_response(
|
||||||
&message.channel,
|
thread_id,
|
||||||
&message.user_id,
|
&message.channel,
|
||||||
&rejection,
|
&message.user_id,
|
||||||
)
|
&rejection,
|
||||||
.await;
|
)
|
||||||
|
.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]
|
#[test]
|
||||||
fn test_queue_cap_rejects_at_capacity() {
|
fn test_queue_cap_rejects_at_capacity() {
|
||||||
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ pub mod import;
|
|||||||
mod logs;
|
mod logs;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
mod models;
|
|
||||||
pub mod oauth_defaults;
|
pub mod oauth_defaults;
|
||||||
mod pairing;
|
mod pairing;
|
||||||
mod registry;
|
mod registry;
|
||||||
@@ -46,7 +45,6 @@ pub use logs::{LogsCommand, run_logs_command};
|
|||||||
pub use mcp::{McpCommand, run_mcp_command};
|
pub use mcp::{McpCommand, run_mcp_command};
|
||||||
pub use memory::MemoryCommand;
|
pub use memory::MemoryCommand;
|
||||||
pub use memory::run_memory_command_with_db;
|
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 pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use registry::{RegistryCommand, run_registry_command};
|
pub use registry::{RegistryCommand, run_registry_command};
|
||||||
pub use routines::{RoutinesCommand, run_routines_command};
|
pub use routines::{RoutinesCommand, run_routines_command};
|
||||||
@@ -219,14 +217,6 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Hooks(HooksCommand),
|
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
|
/// Probe external dependencies and validate configuration
|
||||||
#[command(
|
#[command(
|
||||||
about = "Run diagnostics",
|
about = "Run diagnostics",
|
||||||
|
|||||||
@@ -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
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
models Manage LLM providers and models
|
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
models Manage LLM providers and models
|
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
models Manage LLM providers and models
|
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
models Manage LLM providers and models
|
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -1498,52 +1498,6 @@ impl ExtensionManager {
|
|||||||
Ok(extensions)
|
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.
|
/// Remove an installed extension.
|
||||||
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
||||||
Self::validate_extension_name(name)?;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fmt::Debug;
|
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]
|
#[tokio::test]
|
||||||
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
|
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
|
||||||
-> Result<(), String> {
|
-> Result<(), String> {
|
||||||
|
|||||||
+12
-92
@@ -353,8 +353,6 @@ pub struct Reasoning {
|
|||||||
workspace_system_prompt: Option<String>,
|
workspace_system_prompt: Option<String>,
|
||||||
/// Optional skill context block to inject into system prompt.
|
/// Optional skill context block to inject into system prompt.
|
||||||
skill_context: Option<String>,
|
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 name (e.g. "discord", "telegram") for formatting hints.
|
||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
/// Model name for runtime context.
|
/// Model name for runtime context.
|
||||||
@@ -373,7 +371,6 @@ impl Reasoning {
|
|||||||
llm,
|
llm,
|
||||||
workspace_system_prompt: None,
|
workspace_system_prompt: None,
|
||||||
skill_context: None,
|
skill_context: None,
|
||||||
extension_state_summary: None,
|
|
||||||
channel: None,
|
channel: None,
|
||||||
model_name: None,
|
model_name: None,
|
||||||
is_group_chat: false,
|
is_group_chat: false,
|
||||||
@@ -403,14 +400,6 @@ impl Reasoning {
|
|||||||
self
|
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.
|
/// Set the channel name for channel-specific formatting hints.
|
||||||
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
|
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
|
||||||
let ch = channel.into();
|
let ch = channel.into();
|
||||||
@@ -943,54 +932,21 @@ Example:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
||||||
let has_search = tools.iter().any(|t| t.name == "tool_search");
|
// Only include when the extension management tools are available
|
||||||
let has_list = tools.iter().any(|t| t.name == "tool_list");
|
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
|
||||||
let has_info = tools.iter().any(|t| t.name == "extension_info");
|
if !has_ext_tools {
|
||||||
|
|
||||||
if self.extension_state_summary.is_none() && !has_search && !has_list && !has_info {
|
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut blocks = Vec::new();
|
"\n\n## Extensions\n\
|
||||||
if let Some(ref summary) = self.extension_state_summary {
|
You can search, install, and activate extensions to add new capabilities:\n\
|
||||||
blocks.push(format!(
|
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
||||||
"Current extension state for this user:\n{}",
|
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
||||||
summary
|
- **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."
|
||||||
if has_search || has_list || has_info {
|
.to_string()
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_channel_section(&self) -> 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) ----
|
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -142,11 +142,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
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) => {
|
Some(Command::Doctor) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_doctor_command().await;
|
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
|
|
||||||
Reference in New Issue
Block a user