Compare commits

..
33 changed files with 449 additions and 3037 deletions
+5 -4
View File
@@ -17,8 +17,6 @@ target/
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
# Benchmark results (local runs, not committed)
bench-results/
@@ -36,5 +34,8 @@ trace_*.json
.claude/settings.local.json
.worktrees/
# JetBrains IDE
.idea
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
-9
View File
@@ -47,15 +47,6 @@ impl Agent {
thread_id: Uuid,
initial_messages: Vec<ChatMessage>,
) -> Result<AgenticLoopResult, Error> {
if let Some(ext_mgr) = self.deps.extension_manager.as_ref()
&& let Err(e) = ext_mgr.ensure_nearai_companion_active_if_ready().await
{
tracing::debug!(
"Failed to auto-activate NEAR AI companion MCP before turn: {}",
e
);
}
// Detect group chat from channel metadata (needed before loading system prompt)
let is_group_chat = message
.metadata
+1 -20
View File
@@ -449,8 +449,6 @@ impl AppBuilder {
let mcp_session_manager = Arc::new(McpSessionManager::new());
let mcp_process_manager = Arc::new(McpProcessManager::new());
let companion_mcp_server =
crate::tools::mcp::config::derive_nearai_companion_mcp_server(&self.config);
// Create WASM tool runtime eagerly so extensions installed after startup
// (e.g. via the web UI) can still be activated. The tools directory is only
@@ -528,7 +526,6 @@ impl AppBuilder {
let mcp_sm = Arc::clone(&mcp_session_manager);
let pm = Arc::clone(&mcp_process_manager);
let owner_id = self.config.owner_id.clone();
let companion_mcp_server = companion_mcp_server.clone();
async move {
let servers_result = if let Some(ref d) = db {
load_mcp_servers_from_db(d.as_ref(), &owner_id).await
@@ -536,16 +533,7 @@ impl AppBuilder {
crate::tools::mcp::config::load_mcp_servers().await
};
match servers_result {
Ok(mut servers) => {
if let Some(companion) = companion_mcp_server {
let companion_name = companion.name.clone();
if !servers.insert_if_absent(companion) {
tracing::debug!(
"Skipping derived MCP companion '{}': an existing config with that name is already present",
companion_name
);
}
}
Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() {
tracing::debug!(
@@ -557,8 +545,6 @@ impl AppBuilder {
let mut join_set = tokio::task::JoinSet::new();
for server in enabled {
let mcp_sm = Arc::clone(&mcp_sm);
let nearai_session = Arc::clone(&self.session);
let nearai_api_key = self.config.llm.nearai.api_key.clone();
let secrets = secrets_store.clone();
let tools = Arc::clone(&tools);
let pm = Arc::clone(&pm);
@@ -570,8 +556,6 @@ impl AppBuilder {
let client = match crate::tools::mcp::create_client_from_config(
server,
&mcp_sm,
Some(nearai_session),
nearai_api_key,
&pm,
secrets,
&owner_id,
@@ -728,8 +712,6 @@ impl AppBuilder {
let manager = Arc::new(ExtensionManager::new(
Arc::clone(&mcp_session_manager),
Arc::clone(&mcp_process_manager),
Some(Arc::clone(&self.session)),
self.config.llm.nearai.api_key.clone(),
ext_secrets,
Arc::clone(tools),
Some(Arc::clone(hooks)),
@@ -739,7 +721,6 @@ impl AppBuilder {
self.config.tunnel.public_url.clone(),
self.config.owner_id.clone(),
self.db.clone(),
companion_mcp_server,
catalog_entries.clone(),
));
tools.register_extension_tools(Arc::clone(&manager));
+6 -61
View File
@@ -122,32 +122,18 @@ impl RelayClient {
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let url = format!("{}/oauth/slack/auth", self.base_url);
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(&url)
.get(format!("{}/oauth/slack/auth", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::initiate_oauth: network request failed"
);
RelayError::Network(e.to_string())
})?;
tracing::debug!(
relay_url = %url,
status = %resp.status(),
"RelayClient::initiate_oauth: received response"
);
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
@@ -238,39 +224,20 @@ impl RelayClient {
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
tracing::debug!(
relay_url = %url,
provider = %provider,
method = %method,
"RelayClient::proxy_provider: sending request"
);
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(&url)
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::proxy_provider: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
"RelayClient::proxy_provider: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
@@ -288,45 +255,23 @@ impl RelayClient {
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let url = format!("{}/relay/signing-secret", self.base_url);
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: fetching signing secret"
);
let resp = self
.http
.get(&url)
.get(format!("{}/relay/signing-secret", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::get_signing_secret: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
body = %body,
"RelayClient::get_signing_secret: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
});
}
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: received successful response"
);
let body: serde_json::Value = resp
.json()
-1
View File
@@ -70,7 +70,6 @@ pub async fn extensions_list_handler(
tools: ext.tools,
needs_setup: ext.needs_setup,
has_auth: ext.has_auth,
derived: ext.derived,
activation_status,
activation_error: ext.activation_error,
version: ext.version,
+5 -490
View File
@@ -836,10 +836,10 @@ async fn oauth_callback_handler(
let result: Result<(), String> = async {
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let oauth_proxy_auth_token = flow.oauth_proxy_auth_token().unwrap_or_default();
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
token_url: &flow.token_url,
client_id: &flow.client_id,
client_secret: flow.client_secret.as_deref(),
@@ -1177,31 +1177,11 @@ async fn slack_relay_oauth_callback_handler(
// Store team_id in settings
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
tracing::info!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
team_id_key = %team_id_key,
"relay OAuth callback: storing team_id in settings"
);
store
let _ = store
.set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id))
.await
.map_err(|e| {
tracing::error!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
error = %e,
"relay OAuth callback: failed to persist team_id to settings store"
);
format!("Failed to persist relay team_id: {e}")
})?;
.await;
// Activate the relay channel
tracing::info!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
"relay OAuth callback: activating relay channel"
);
ext_mgr
.activate_stored_relay(DEFAULT_RELAY_NAME, &state.owner_id)
.await
@@ -2092,7 +2072,6 @@ async fn extensions_list_handler(
tools: ext.tools,
needs_setup: ext.needs_setup,
has_auth: ext.has_auth,
derived: ext.derived,
activation_status,
activation_error: ext.activation_error,
version: ext.version,
@@ -2202,11 +2181,6 @@ async fn extensions_activate_handler(
AuthenticatedUser(user): AuthenticatedUser,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
tracing::debug!(
extension = %name,
user_id = %user.user_id,
"extensions_activate_handler: received activate request"
);
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
@@ -2214,10 +2188,6 @@ async fn extensions_activate_handler(
match ext_mgr.activate(&name, &user.user_id).await {
Ok(result) => {
tracing::info!(
extension = %name,
"extensions_activate_handler: activation succeeded"
);
// Activation loaded the WASM module. Check if the tool needs
// OAuth scope expansion (e.g., adding google-docs when gmail
// already has a token but missing the documents scope).
@@ -2236,13 +2206,6 @@ async fn extensions_activate_handler(
crate::extensions::ExtensionError::AuthRequired
);
tracing::debug!(
extension = %name,
error = %activate_err,
needs_auth = needs_auth,
"extensions_activate_handler: activation failed, attempting auth fallback"
);
if !needs_auth {
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
}
@@ -2250,21 +2213,10 @@ async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, &user.user_id).await {
Ok(auth_result) if auth_result.is_authenticated() => {
tracing::debug!(
extension = %name,
"extensions_activate_handler: auth reports authenticated, retrying activate"
);
// Auth succeeded, retry activation.
match ext_mgr.activate(&name, &user.user_id).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"extensions_activate_handler: retry after auth still failed"
);
Ok(Json(ActionResponse::fail(e.to_string())))
}
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
Ok(auth_result) => {
@@ -2949,7 +2901,6 @@ mod tests {
tools: Vec::new(),
needs_setup: true,
has_auth: false,
derived: false,
installed: true,
activation_error: None,
version: None,
@@ -2987,7 +2938,6 @@ mod tests {
tools: Vec::new(),
needs_setup: true,
has_auth: false,
derived: false,
installed: true,
activation_error: None,
version: None,
@@ -3060,160 +3010,6 @@ mod tests {
.with_state(state)
}
#[derive(Clone, Debug)]
struct RecordedOauthProxyRequest {
authorization: Option<String>,
form: std::collections::HashMap<String, String>,
}
#[derive(Clone)]
struct MockOauthProxyState {
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
}
struct MockOauthProxyServer {
addr: std::net::SocketAddr,
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
server_task: Option<tokio::task::JoinHandle<()>>,
}
impl MockOauthProxyServer {
async fn start() -> Self {
async fn exchange_handler(
State(state): State<MockOauthProxyState>,
headers: axum::http::HeaderMap,
axum::Form(form): axum::Form<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
state.requests.lock().await.push(RecordedOauthProxyRequest {
authorization: headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string),
form,
});
Json(serde_json::json!({
"access_token": "proxy-access-token",
"refresh_token": "proxy-refresh-token",
"expires_in": 7200
}))
}
let requests = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock oauth proxy");
let addr = listener.local_addr().expect("mock oauth proxy addr");
let app = Router::new()
.route("/oauth/exchange", post(exchange_handler))
.with_state(MockOauthProxyState {
requests: Arc::clone(&requests),
});
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let server_task = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await;
});
Self {
addr,
requests,
shutdown_tx: Some(shutdown_tx),
server_task: Some(server_task),
}
}
fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
async fn requests(&self) -> Vec<RecordedOauthProxyRequest> {
self.requests.lock().await.clone()
}
async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(task) = self.server_task.take() {
let _ = task.await;
}
}
}
impl Drop for MockOauthProxyServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(task) = self.server_task.take() {
task.abort();
}
}
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// SAFETY: Tests use lock_env() to serialize environment access.
unsafe {
if let Some(ref value) = self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
let original = std::env::var(key).ok();
// SAFETY: Tests use lock_env() to serialize environment access.
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
EnvVarGuard { key, original }
}
fn fresh_pending_oauth_flow(
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
sse_manager: Option<Arc<SseManager>>,
oauth_proxy_auth_token: Option<String>,
) -> crate::cli::oauth_defaults::PendingOAuthFlow {
crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: Some("test-code-verifier".to_string()),
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: Some("google".to_string()),
validation_endpoint: None,
scopes: vec!["email".to_string()],
user_id: "test".to_string(),
secrets,
sse_manager,
gateway_token: oauth_proxy_auth_token,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at: std::time::Instant::now(),
}
}
#[tokio::test]
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
use axum::body::Body;
@@ -3871,284 +3667,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_oauth_callback_accepts_versioned_hosted_state_without_instance_name() {
use axum::body::Body;
use tower::ServiceExt;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let Some(created_at) = expired_flow_created_at() else {
eprintln!(
"Skipping versioned OAuth state without instance test: monotonic uptime below expiry window"
);
return;
};
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: None,
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_manager: None,
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
let versioned_state =
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/callback?code=fake_code&state={}",
urlencoding::encode(&versioned_state)
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn test_oauth_callback_happy_path_with_gateway_token_fallback() {
use axum::body::Body;
use tower::ServiceExt;
let proxy = MockOauthProxyServer::start().await;
// Keep the process-wide env locked for the full callback so the handler
// sees a stable proxy URL/token configuration throughout the test.
let _env_guard = crate::config::helpers::lock_env();
let _exchange_url_guard =
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
let _proxy_auth_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let secrets = test_secrets_store();
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
let sse_mgr = Arc::new(SseManager::new());
let mut receiver = sse_mgr.sender().subscribe();
let flow = fresh_pending_oauth_flow(
Arc::clone(&secrets),
Some(Arc::clone(&sse_mgr)),
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
);
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
let versioned_state =
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/callback?code=fake_code&state={}",
urlencoding::encode(&versioned_state)
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Test Tool Connected"));
let requests = proxy.requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer gateway-test-token")
);
assert_eq!(
requests[0].form.get("code").map(String::as_str),
Some("fake_code")
);
assert_eq!(
requests[0].form.get("code_verifier").map(String::as_str),
Some("test-code-verifier")
);
let access_token = secrets
.get_decrypted("test", "test_token")
.await
.expect("access token stored");
assert_eq!(access_token.expose(), "proxy-access-token");
let refresh_token = secrets
.get_decrypted("test", "test_token_refresh_token")
.await
.expect("refresh token stored");
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
match receiver.recv().await.expect("auth_completed event").event {
crate::channels::web::types::AppEvent::AuthCompleted {
extension_name,
success,
..
} => {
assert_eq!(extension_name, "test_tool");
assert!(success, "OAuth callback should broadcast success");
}
event => panic!("expected AuthCompleted event, got {event:?}"),
}
proxy.shutdown().await;
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn test_oauth_callback_happy_path_with_dedicated_proxy_auth_token() {
use axum::body::Body;
use tower::ServiceExt;
let proxy = MockOauthProxyServer::start().await;
// Keep the process-wide env locked for the full callback so the handler
// sees a stable proxy URL/token configuration throughout the test.
let _env_guard = crate::config::helpers::lock_env();
let _exchange_url_guard =
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
let _proxy_auth_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-oauth-proxy-secret"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let secrets = test_secrets_store();
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
let sse_mgr = Arc::new(SseManager::new());
let mut receiver = sse_mgr.sender().subscribe();
let flow = fresh_pending_oauth_flow(
Arc::clone(&secrets),
Some(Arc::clone(&sse_mgr)),
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
);
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
let versioned_state =
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/callback?code=fake_code&state={}",
urlencoding::encode(&versioned_state)
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Test Tool Connected"));
let requests = proxy.requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer shared-oauth-proxy-secret")
);
assert_eq!(
requests[0].form.get("code").map(String::as_str),
Some("fake_code")
);
assert_eq!(
requests[0].form.get("code_verifier").map(String::as_str),
Some("test-code-verifier")
);
let access_token = secrets
.get_decrypted("test", "test_token")
.await
.expect("access token stored");
assert_eq!(access_token.expose(), "proxy-access-token");
let refresh_token = secrets
.get_decrypted("test", "test_token_refresh_token")
.await
.expect("refresh token stored");
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
match receiver.recv().await.expect("auth_completed event").event {
crate::channels::web::types::AppEvent::AuthCompleted {
extension_name,
success,
..
} => {
assert_eq!(extension_name, "test_tool");
assert!(success, "OAuth callback should broadcast success");
}
event => panic!("expected AuthCompleted event, got {event:?}"),
}
proxy.shutdown().await;
}
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
@@ -4180,8 +3698,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
mcp_pm,
None,
None,
secrets,
tool_registry,
None,
@@ -4191,7 +3707,6 @@ mod tests {
None,
"test".to_string(),
None,
None,
vec![],
));
(ext_mgr, wasm_tools_dir, wasm_channels_dir)
+10 -23
View File
@@ -1160,7 +1160,7 @@ function addToolCard(name) {
const toolName = document.createElement('span');
toolName.className = 'activity-tool-name';
toolName.textContent = humanizeToolName(name);
toolName.textContent = name;
const duration = document.createElement('span');
duration.className = 'activity-tool-duration';
@@ -1344,7 +1344,7 @@ function finalizeActivityGroup() {
function humanizeToolName(rawName) {
if (!rawName) return '';
return stripDerivedCompanionToolPrefix(String(rawName))
return String(rawName)
.replace(/[_-]+/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^tool([a-zA-Z])/, 'tool $1')
@@ -1352,12 +1352,6 @@ function humanizeToolName(rawName) {
.trim();
}
function stripDerivedCompanionToolPrefix(rawName) {
if (!rawName) return '';
const prefix = '_nearai_companion_mcp_';
return rawName.startsWith(prefix) ? rawName.slice(prefix.length) : rawName;
}
function shouldShowChannelConnectedMessage(extensionName, success) {
if (!success || !extensionName) return false;
return String(extensionName).toLowerCase().includes('telegram');
@@ -1877,7 +1871,7 @@ function createToolCallsSummaryElement(toolCalls) {
const icon = tc.has_error ? '\u2717' : '\u2713';
const nameSpan = document.createElement('span');
nameSpan.className = 'tool-call-name';
nameSpan.textContent = icon + ' ' + humanizeToolName(tc.name);
nameSpan.textContent = icon + ' ' + tc.name;
item.appendChild(nameSpan);
if (tc.result_preview) {
@@ -2912,12 +2906,7 @@ function renderExtensionCard(ext) {
if (ext.tools && ext.tools.length > 0) {
const tools = document.createElement('div');
tools.className = 'ext-tools';
const toolNames = ext.tools.map((toolName) => (
ext.derived && ext.kind === 'mcp_server'
? stripDerivedCompanionToolPrefix(toolName)
: toolName
));
tools.textContent = 'Tools: ' + toolNames.join(', ');
tools.textContent = 'Tools: ' + ext.tools.join(', ');
card.appendChild(tools);
}
@@ -2978,7 +2967,7 @@ function renderExtensionCard(ext) {
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
// this means OAuth credentials resolve automatically (builtin/env) and the user
// just needs to complete the OAuth flow, not fill in a config form.
if (!ext.derived && (ext.needs_setup || (ext.has_auth && ext.authenticated))) {
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
@@ -2987,13 +2976,11 @@ function renderExtensionCard(ext) {
}
}
if (!ext.derived) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.addEventListener('click', () => removeExtension(ext.name));
actions.appendChild(removeBtn);
}
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.addEventListener('click', () => removeExtension(ext.name));
actions.appendChild(removeBtn);
card.appendChild(actions);
-3
View File
@@ -344,9 +344,6 @@ pub struct ExtensionInfo {
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// Whether this extension is derived from runtime/provider state.
#[serde(default)]
pub derived: bool,
/// WASM channel activation status.
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_status: Option<ExtensionActivationStatus>,
+42 -326
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use clap::{Args, Subcommand};
use crate::config::{Config, LlmConfig};
use crate::config::Config;
use crate::db::Database;
use crate::secrets::SecretsStore;
use crate::tools::mcp::{
@@ -173,13 +173,6 @@ async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {
description,
} = args;
if config::is_nearai_companion_server_name(&name) {
anyhow::bail!(
"Server name '{}' is reserved for the NEAR AI companion MCP server",
name
);
}
let transport_lower = transport.to_lowercase();
let mut config = match transport_lower.as_str() {
@@ -251,7 +244,7 @@ async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {
// Save (DB if available, else disk)
let db = connect_db().await;
let mut servers = load_persisted_servers(db.as_deref()).await?;
let mut servers = load_servers(db.as_deref()).await?;
servers.upsert(config);
save_servers(db.as_deref(), &servers).await?;
@@ -288,15 +281,8 @@ async fn add_server(args: McpAddArgs) -> anyhow::Result<()> {
/// Remove an MCP server.
async fn remove_server(name: String) -> anyhow::Result<()> {
if config::is_nearai_companion_server_name(&name) {
anyhow::bail!(
"Server '{}' is derived from the active NEAR AI provider and cannot be removed directly",
name
);
}
let db = connect_db().await;
let mut servers = load_persisted_servers(db.as_deref()).await?;
let mut servers = load_servers(db.as_deref()).await?;
if !servers.remove(&name) {
anyhow::bail!("Server '{}' not found", name);
}
@@ -312,7 +298,7 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
/// List configured MCP servers.
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
let db = connect_db().await;
let servers = load_servers_with_derived(db.as_deref()).await?;
let servers = load_servers(db.as_deref()).await?;
if servers.servers.is_empty() {
println!();
@@ -418,23 +404,12 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config
let db = connect_db().await;
let servers = load_servers_with_derived(db.as_deref()).await?;
let servers = load_servers(db.as_deref()).await?;
let server = servers
.get(&name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
if server.uses_runtime_auth_source() {
println!();
println!(
" Server '{}' reuses your active NEAR AI authentication and does not support separate MCP OAuth.",
name
);
println!(" Configure NEAR AI auth (API key or session login) instead.");
println!();
return Ok(());
}
// Initialize secrets store
let secrets = get_secrets_store().await?;
@@ -502,7 +477,7 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config
let db = connect_db().await;
let servers = load_servers_with_derived(db.as_deref()).await?;
let servers = load_servers(db.as_deref()).await?;
let server = servers
.get(&name)
.cloned()
@@ -513,66 +488,35 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
// Create client
let session_manager = Arc::new(McpSessionManager::new());
let (client, has_tokens) = if server.uses_runtime_auth_source() {
let process_manager = Arc::new(McpProcessManager::new());
let llm = resolve_llm_for_cli(as_settings_store(db.as_deref())).await?;
let nearai_session = crate::llm::create_session_manager(llm.session.clone()).await;
(
create_client_from_config(
server.clone(),
&session_manager,
Some(nearai_session),
llm.nearai.api_key.clone(),
&process_manager,
None,
"default",
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?,
false,
)
} else {
// Only initialize the secrets store for non-runtime-auth servers that
// can actually use persisted OAuth/DCR tokens.
let secrets = get_secrets_store().await?;
let has_tokens = is_authenticated(&server, &secrets, &user_id).await;
if has_tokens {
(
McpClient::new_authenticated(
server.clone(),
session_manager.clone(),
secrets,
user_id,
),
true,
)
} else if server.requires_auth() {
println!();
println!(
" ✗ Not authenticated. Run 'ironclaw mcp auth {}' first.",
name
);
println!();
return Ok(());
} else {
// Use the factory to dispatch on transport type (HTTP, stdio, unix)
let process_manager = Arc::new(McpProcessManager::new());
(
create_client_from_config(
server.clone(),
&session_manager,
None,
None,
&process_manager,
None,
"default",
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?,
false,
)
}
// Always check for stored tokens (from either pre-configured OAuth or DCR)
let secrets = get_secrets_store().await?;
let has_tokens = is_authenticated(&server, &secrets, &user_id).await;
let client = if has_tokens {
// We have stored tokens, use authenticated client
McpClient::new_authenticated(server.clone(), session_manager.clone(), secrets, user_id)
} else if server.requires_auth() {
// OAuth configured but no tokens - need to authenticate
println!();
println!(
" ✗ Not authenticated. Run 'ironclaw mcp auth {}' first.",
name
);
println!();
return Ok(());
} else {
// Use the factory to dispatch on transport type (HTTP, stdio, unix)
let process_manager = Arc::new(McpProcessManager::new());
create_client_from_config(
server.clone(),
&session_manager,
&process_manager,
None,
"default",
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
};
// Test connection
@@ -637,15 +581,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
/// Toggle server enabled/disabled state.
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
if config::is_nearai_companion_server_name(&name) {
anyhow::bail!(
"Server '{}' is derived from the active NEAR AI provider and cannot be toggled directly",
name
);
}
let db = connect_db().await;
let mut servers = load_persisted_servers(db.as_deref()).await?;
let mut servers = load_servers(db.as_deref()).await?;
let server = servers
.get_mut(&name)
@@ -678,30 +615,13 @@ async fn connect_db() -> Option<Arc<dyn Database>> {
crate::db::connect_from_config(&config.database).await.ok()
}
/// Load only persisted MCP servers (DB if available, else disk).
async fn load_persisted_servers(
db: Option<&dyn Database>,
) -> Result<McpServersFile, config::ConfigError> {
Ok(if let Some(db) = db {
config::load_mcp_servers_from_db(db, DEFAULT_USER_ID).await?
/// Load MCP servers (DB if available, else disk).
async fn load_servers(db: Option<&dyn Database>) -> Result<McpServersFile, config::ConfigError> {
if let Some(db) = db {
config::load_mcp_servers_from_db(db, DEFAULT_USER_ID).await
} else {
config::load_mcp_servers().await?
})
}
/// Load MCP servers plus any derived runtime companions.
async fn load_servers_with_derived(
db: Option<&dyn Database>,
) -> Result<McpServersFile, config::ConfigError> {
let mut servers = load_persisted_servers(db).await?;
if let Ok(llm) = resolve_llm_for_cli(as_settings_store(db)).await
&& let Some(companion) = config::derive_nearai_companion_mcp_server_from_llm(&llm)
{
servers.insert_if_absent(companion);
config::load_mcp_servers().await
}
Ok(servers)
}
/// Save MCP servers (DB if available, else disk).
@@ -709,15 +629,10 @@ async fn save_servers(
db: Option<&dyn Database>,
servers: &McpServersFile,
) -> Result<(), config::ConfigError> {
let mut persisted = servers.clone();
persisted
.servers
.retain(|server| !config::is_nearai_companion_server_name(&server.name));
if let Some(db) = db {
config::save_mcp_servers_to_db(db, DEFAULT_USER_ID, &persisted).await
config::save_mcp_servers_to_db(db, DEFAULT_USER_ID, servers).await
} else {
config::save_mcp_servers(&persisted).await
config::save_mcp_servers(servers).await
}
}
@@ -726,84 +641,10 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
crate::cli::init_secrets_store().await
}
fn as_settings_store(db: Option<&dyn Database>) -> Option<&(dyn crate::db::SettingsStore + Sync)> {
db.map(|db| db as &(dyn crate::db::SettingsStore + Sync))
}
async fn resolve_llm_for_cli(
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
) -> Result<LlmConfig, crate::error::ConfigError> {
resolve_llm_for_cli_with_toml(store, None).await
}
async fn resolve_llm_for_cli_with_toml(
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
toml_path: Option<&std::path::Path>,
) -> Result<LlmConfig, crate::error::ConfigError> {
if let Some(store) = store {
let _ = dotenvy::dotenv();
crate::bootstrap::load_ironclaw_env();
let mut settings = match store.get_all_settings(DEFAULT_USER_ID).await {
Ok(map) => crate::settings::Settings::from_db_map(&map),
Err(e) => {
tracing::warn!(
"Failed to load CLI settings from DB, falling back to defaults before env/TOML resolution: {}",
e
);
crate::settings::Settings::default()
}
};
apply_cli_toml_overlay(&mut settings, toml_path)?;
return LlmConfig::resolve(&settings);
}
let settings = crate::config::load_bootstrap_settings(toml_path)?;
LlmConfig::resolve(&settings)
}
fn apply_cli_toml_overlay(
settings: &mut crate::settings::Settings,
explicit_path: Option<&std::path::Path>,
) -> Result<(), crate::error::ConfigError> {
let path = explicit_path
.map(std::path::PathBuf::from)
.unwrap_or_else(crate::settings::Settings::default_toml_path);
match crate::settings::Settings::load_toml(&path) {
Ok(Some(toml_settings)) => {
settings.merge_from(&toml_settings);
}
Ok(None) => {
if explicit_path.is_some() {
return Err(crate::error::ConfigError::ParseError(format!(
"Config file not found: {}",
path.display()
)));
}
}
Err(e) => {
return Err(crate::error::ConfigError::ParseError(e));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use async_trait::async_trait;
use crate::error::DatabaseError;
use crate::history::SettingRow;
#[cfg(feature = "libsql")]
use tempfile::NamedTempFile;
#[test]
fn test_mcp_command_parsing() {
// Just verify the command structure is valid
@@ -860,129 +701,4 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid env var format"));
}
#[cfg(feature = "libsql")]
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn test_resolve_llm_for_cli_uses_db_backed_selected_model() {
struct MockSettingsStore {
settings: HashMap<String, serde_json::Value>,
}
#[async_trait]
impl crate::db::SettingsStore for MockSettingsStore {
async fn get_setting(
&self,
_user_id: &str,
key: &str,
) -> Result<Option<serde_json::Value>, DatabaseError> {
Ok(self.settings.get(key).cloned())
}
async fn get_setting_full(
&self,
_user_id: &str,
_key: &str,
) -> Result<Option<SettingRow>, DatabaseError> {
Ok(None)
}
async fn set_setting(
&self,
_user_id: &str,
_key: &str,
_value: &serde_json::Value,
) -> Result<(), DatabaseError> {
Err(DatabaseError::Query("unused in test".to_string()))
}
async fn delete_setting(
&self,
_user_id: &str,
_key: &str,
) -> Result<bool, DatabaseError> {
Err(DatabaseError::Query("unused in test".to_string()))
}
async fn list_settings(
&self,
_user_id: &str,
) -> Result<Vec<SettingRow>, DatabaseError> {
Ok(Vec::new())
}
async fn get_all_settings(
&self,
_user_id: &str,
) -> Result<HashMap<String, serde_json::Value>, DatabaseError> {
Ok(self.settings.clone())
}
async fn set_all_settings(
&self,
_user_id: &str,
_settings: &HashMap<String, serde_json::Value>,
) -> Result<(), DatabaseError> {
Err(DatabaseError::Query("unused in test".to_string()))
}
async fn has_settings(&self, _user_id: &str) -> Result<bool, DatabaseError> {
Ok(!self.settings.is_empty())
}
}
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: Protected by ENV_MUTEX for the duration of the test.
unsafe {
match &self.1 {
Some(value) => std::env::set_var(self.0, value),
None => std::env::remove_var(self.0),
}
}
}
}
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let prev_backend = std::env::var("LLM_BACKEND").ok();
let prev_base_url = std::env::var("NEARAI_BASE_URL").ok();
let prev_auth_url = std::env::var("NEARAI_AUTH_URL").ok();
let prev_model = std::env::var("NEARAI_MODEL").ok();
// SAFETY: Protected by ENV_MUTEX for the duration of the test.
unsafe {
std::env::set_var("LLM_BACKEND", "");
std::env::set_var("NEARAI_BASE_URL", "http://127.0.0.1:11434/v1");
std::env::set_var("NEARAI_AUTH_URL", "http://127.0.0.1:11435");
std::env::set_var("NEARAI_MODEL", "");
}
let _backend_guard = EnvGuard("LLM_BACKEND", prev_backend);
let _base_url_guard = EnvGuard("NEARAI_BASE_URL", prev_base_url);
let _auth_url_guard = EnvGuard("NEARAI_AUTH_URL", prev_auth_url);
let _model_guard = EnvGuard("NEARAI_MODEL", prev_model);
let empty_toml = NamedTempFile::new().expect("temp toml");
let store = MockSettingsStore {
settings: HashMap::from([
("llm_backend".to_string(), serde_json::json!("nearai")),
(
"selected_model".to_string(),
serde_json::json!("db-backed-nearai-model"),
),
]),
};
let llm = resolve_llm_for_cli_with_toml(Some(&store), Some(empty_toml.path()))
.await
.expect("resolve llm");
assert_eq!(llm.backend, "nearai");
assert_eq!(llm.nearai.model, "db-backed-nearai-model");
let companion =
config::derive_nearai_companion_mcp_server_from_llm(&llm).expect("derived companion");
assert_eq!(companion.url, "http://127.0.0.1:11434/mcp");
}
}
+5 -184
View File
@@ -473,8 +473,7 @@ pub struct PendingOAuthFlow {
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
/// SSE broadcast manager for notifying the web UI.
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
@@ -497,12 +496,6 @@ impl std::fmt::Debug for PendingOAuthFlow {
}
}
impl PendingOAuthFlow {
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
@@ -536,22 +529,6 @@ pub fn exchange_proxy_url() -> Option<String> {
.filter(|url| !url.is_empty())
}
/// Returns the configured OAuth proxy auth token, if any.
///
/// New hosted infra can inject a dedicated shared proxy secret via
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
pub fn oauth_proxy_auth_token() -> Option<String> {
fn normalized_env_value(key: &str) -> Option<String> {
crate::config::helpers::env_or_override(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -697,8 +674,6 @@ pub fn strip_instance_prefix(state: &str) -> &str {
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -712,8 +687,6 @@ pub struct ProxyTokenExchangeRequest<'a> {
pub struct ProxyRefreshTokenRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -756,7 +729,7 @@ fn oauth_token_response_from_json(
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
@@ -768,7 +741,7 @@ pub async fn exchange_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token exchange".to_string(),
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
@@ -823,7 +796,7 @@ pub async fn exchange_via_proxy(
/// Refresh an OAuth access token via the platform's token refresh proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
pub async fn refresh_token_via_proxy(
@@ -831,7 +804,7 @@ pub async fn refresh_token_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token refresh".to_string(),
"Gateway auth token is required for proxy token refresh".to_string(),
));
}
@@ -1037,37 +1010,6 @@ mod tests {
}
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(ref value) = self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
let original = std::env::var(key).ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
EnvVarGuard { key, original }
}
#[test]
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
@@ -1088,79 +1030,6 @@ mod tests {
assert_eq!(result, client_secret);
}
#[tokio::test]
async fn test_exchange_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
let mut extra_token_params = HashMap::new();
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
proxy_url: &server.base_url(),
gateway_token: "shared-oauth-proxy-secret",
code: "auth-code-123",
redirect_uri: "https://oauth.example.com/oauth/callback",
token_url: "https://oauth2.googleapis.com/token",
client_id: TEST_OAUTH_CLIENT_ID,
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
access_token_field: "access_token",
code_verifier: Some("code-verifier-123"),
extra_token_params: &extra_token_params,
})
.await
.expect("proxy exchange succeeds");
assert_eq!(response.access_token, "proxy-access-token");
assert_eq!(
response.refresh_token.as_deref(),
Some("proxy-refresh-token")
);
assert_eq!(response.expires_in, Some(7200));
let requests = server.requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer shared-oauth-proxy-secret")
);
assert_eq!(
requests[0].form.get("code").map(String::as_str),
Some("auth-code-123")
);
assert_eq!(
requests[0].form.get("redirect_uri").map(String::as_str),
Some("https://oauth.example.com/oauth/callback")
);
assert_eq!(
requests[0].form.get("token_url").map(String::as_str),
Some("https://oauth2.googleapis.com/token")
);
assert_eq!(
requests[0].form.get("client_id").map(String::as_str),
Some(TEST_OAUTH_CLIENT_ID)
);
assert_eq!(
requests[0].form.get("client_secret").map(String::as_str),
Some(TEST_OAUTH_CLIENT_SECRET)
);
assert_eq!(
requests[0]
.form
.get("access_token_field")
.map(String::as_str),
Some("access_token")
);
assert_eq!(
requests[0].form.get("code_verifier").map(String::as_str),
Some("code-verifier-123")
);
assert_eq!(
requests[0].form.get("resource").map(String::as_str),
Some("https://mcp.notion.com")
);
server.shutdown().await;
}
#[tokio::test]
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
@@ -1666,54 +1535,6 @@ mod tests {
}
}
#[test]
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-proxy-secret"),
);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("shared-proxy-secret")
);
}
#[test]
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
}
#[test]
fn test_strip_instance_prefix_with_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
-3
View File
@@ -192,9 +192,6 @@ pub struct JobContext {
/// but subsequent tools (e.g., `json`) may need the full output. This
/// stash stores the complete, unsanitized output so tools can reference
/// previous results by ID via `$tool_call_id` parameter syntax.
///
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
/// as `__routine_last_name` for fallback recovery in routine tool chains.
#[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
+54 -868
View File
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -2,7 +2,8 @@
//! and activation of channels, tools, and MCP servers.
//!
//! Extensions are the user-facing abstraction that unifies three runtime kinds:
//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM)
//! - **Channels** (Telegram, Slack, Discord) — messaging platform connections
//! and conversation transports (WASM)
//! - **Tools** — sandboxed capabilities (WASM)
//! - **MCP servers** — external API integrations via Model Context Protocol
//!
@@ -506,10 +507,6 @@ pub struct InstalledExtension {
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// Whether this extension is derived from provider/runtime state instead of
/// being a user-managed persisted configuration.
#[serde(default)]
pub derived: bool,
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
@@ -940,7 +937,6 @@ mod tests {
assert!(ext.installed, "installed should default to true");
assert!(!ext.needs_setup, "needs_setup should default to false");
assert!(!ext.has_auth);
assert!(!ext.derived);
assert!(ext.tools.is_empty());
assert!(ext.display_name.is_none());
assert!(ext.description.is_none());
@@ -961,7 +957,6 @@ mod tests {
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
needs_setup: true,
has_auth: true,
derived: true,
installed: false,
activation_error: Some("token expired".to_string()),
version: None,
@@ -971,7 +966,6 @@ mod tests {
assert_eq!(json["description"], "Read and send emails");
assert_eq!(json["url"], "https://gmail.example.com");
assert_eq!(json["needs_setup"], true);
assert_eq!(json["derived"], true);
assert_eq!(json["installed"], false);
assert_eq!(json["activation_error"], "token expired");
@@ -979,7 +973,6 @@ mod tests {
assert_eq!(back.name, "gmail");
assert_eq!(back.tools.len(), 2);
assert!(back.needs_setup);
assert!(back.derived);
assert!(!back.installed);
assert_eq!(back.activation_error.as_deref(), Some("token expired"));
}
-2
View File
@@ -21,7 +21,6 @@ pub mod failover;
pub mod gemini_oauth;
mod github_copilot;
pub(crate) mod github_copilot_auth;
pub mod nearai_auth;
mod nearai_chat;
pub mod oauth_helpers;
pub mod openai_codex_provider;
@@ -54,7 +53,6 @@ pub use config::{
pub use error::LlmError;
pub use failover::{CooldownConfig, FailoverProvider};
pub use gemini_oauth::GeminiOauthProvider;
pub use nearai_auth::{resolve_nearai_bearer_token, resolve_nearai_bearer_token_if_available};
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
pub use openai_codex_provider::OpenAiCodexProvider;
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
-95
View File
@@ -1,95 +0,0 @@
use secrecy::{ExposeSecret, SecretString};
use crate::llm::LlmError;
use crate::llm::session::SessionManager;
/// Resolve the active NEAR AI bearer token only if already available.
///
/// Unlike [`resolve_nearai_bearer_token`], this helper is side-effect free:
/// it never triggers an interactive login flow.
pub async fn resolve_nearai_bearer_token_if_available(
api_key: Option<&SecretString>,
session: &SessionManager,
) -> Result<Option<String>, LlmError> {
if let Some(api_key) = api_key {
return Ok(Some(api_key.expose_secret().to_string()));
}
if session.has_token().await {
let token = session.get_token().await?;
return Ok(Some(token.expose_secret().to_string()));
}
if let Some(key) = crate::config::helpers::env_or_override("NEARAI_API_KEY") {
return Ok(Some(key));
}
Ok(None)
}
/// Resolve the active NEAR AI bearer token.
///
/// Priority order:
/// 1. Explicit API key from resolved config
/// 2. Existing session token
/// 3. Interactive session authentication
/// 4. `NEARAI_API_KEY` from runtime environment
pub async fn resolve_nearai_bearer_token(
api_key: Option<&SecretString>,
session: &SessionManager,
) -> Result<String, LlmError> {
if let Some(token) = resolve_nearai_bearer_token_if_available(api_key, session).await? {
return Ok(token);
}
session.ensure_authenticated().await?;
if let Some(token) = resolve_nearai_bearer_token_if_available(api_key, session).await? {
return Ok(token);
}
Err(LlmError::AuthFailed {
provider: "nearai".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::{ENV_MUTEX, set_runtime_env};
use crate::llm::session::SessionConfig;
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: tests hold ENV_MUTEX while mutating the process environment.
unsafe {
match &self.1 {
Some(value) => std::env::set_var(self.0, value),
None => std::env::remove_var(self.0),
}
}
set_runtime_env(self.0, "");
}
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn test_resolve_bearer_token_if_available_uses_runtime_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
let prev = std::env::var("NEARAI_API_KEY").ok();
// SAFETY: tests hold ENV_MUTEX while mutating the process environment.
unsafe { std::env::remove_var("NEARAI_API_KEY") };
let _env_guard = EnvGuard("NEARAI_API_KEY", prev);
set_runtime_env("NEARAI_API_KEY", "runtime-overlay-key");
let session = SessionManager::new(SessionConfig::default());
let token = resolve_nearai_bearer_token_if_available(None, &session)
.await
.expect("resolve token");
assert_eq!(token.as_deref(), Some("runtime-overlay-key"));
}
}
+30 -1
View File
@@ -173,7 +173,36 @@ impl NearAiChatProvider {
/// The env var fallback (#3) only triggers after `ensure_authenticated()`
/// runs, because `api_key_login()` sets the env var but not a session token.
async fn resolve_bearer_token(&self) -> Result<String, LlmError> {
crate::llm::resolve_nearai_bearer_token(self.config.api_key.as_ref(), &self.session).await
// 1. Config-level API key takes priority
if let Some(ref api_key) = self.config.api_key {
return Ok(api_key.expose_secret().to_string());
}
// 2. Existing session token (OAuth was already completed)
if self.session.has_token().await {
let token = self.session.get_token().await?;
return Ok(token.expose_secret().to_string());
}
// No token yet, trigger interactive login
self.session.ensure_authenticated().await?;
// 3. After login, check if a session token was stored (OAuth path)
if self.session.has_token().await {
let token = self.session.get_token().await?;
return Ok(token.expose_secret().to_string());
}
// 4. api_key_login() sets NEARAI_API_KEY env var but not a session token
if let Ok(key) = std::env::var("NEARAI_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
Err(LlmError::AuthFailed {
provider: "nearai".to_string(),
})
}
/// Send a single request to the chat completions API.
+64 -6
View File
@@ -1017,8 +1017,11 @@ Example:
"\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\
- **Channels** (Telegram, Slack, Discord) connect messaging platforms so users can \
talk to you there. When users ask about connecting a messaging platform, search for it \
as a channel. Channels are not separate send-message tools; use normal assistant output \
to reply in the current conversation, and use the `message` tool only for proactive, \
background, or cross-channel outbound sends.\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 \
@@ -1059,15 +1062,20 @@ Example:
let message_tool_hint = "\
\n\n## Proactive Messaging\n\
For ordinary replies in the current conversation, respond normally without calling `message`.\n\
Send messages via Signal, Telegram, Slack, or other connected channels:\n\
- `content` (required): the message text\n\
- `attachments` (optional): array of file paths to send\n\
- `channel` (optional): which channel to use (signal, telegram, slack, etc.)\n\
- `target` (optional): who to send to (phone number, group ID, etc.)\n\
\nOmit both `channel` and `target` to send to the current conversation.\n\
\nOmit both `channel` and `target` for a proactive follow-up in the current conversation.\n\
Target formats:\n\
- Signal: E.164 phone number (`+1234567890`) or group ID\n\
- Telegram: username or chat ID\n\
- Slack: channel name (`#general`) or user ID\n\
Examples (tool calls use JSON format):\n\
- Reply here: {\"content\": \"Hi!\"}\n\
- Send file here: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
- Proactive follow-up here: {\"content\": \"Hi again!\"}\n\
- Send file here proactively: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
- Message a different user: {\"channel\": \"signal\", \"target\": \"+1234567890\", \"content\": \"Hi!\"}\n\
- Message a different group: {\"channel\": \"signal\", \"target\": \"group:abc123\", \"content\": \"Hi!\"}";
@@ -1105,7 +1113,9 @@ Examples (tool calls use JSON format):\n\
format!(
"\n\n## Current Conversation\n\
This is who you're talking to (omit 'target' to send here):\n{}",
This is who you're talking to in the active conversation. Use normal assistant \
output to reply here; only use the `message` tool for proactive, background, or \
cross-channel outbound sends:\n{}",
lines.join("\n")
)
}
@@ -2452,6 +2462,54 @@ That's my plan."#;
);
}
#[test]
fn test_extensions_section_clarifies_channels_are_not_send_tools() {
let reasoning = make_test_reasoning();
let tool_defs = vec![ToolDefinition {
name: "tool_search".to_string(),
description: "Search extensions".to_string(),
parameters: serde_json::json!({}),
}];
let section = reasoning.build_extensions_section_for_tools(&tool_defs);
assert!(section.contains("connect messaging platforms so users can talk to you there"));
assert!(section.contains("Channels are not separate send-message tools"));
assert!(
section.contains("use normal assistant output to reply in the current conversation")
);
assert!(section.contains(
"`message` tool only for proactive, background, or cross-channel outbound sends"
));
}
#[test]
fn test_channel_section_separates_normal_replies_from_message_tool() {
let reasoning = make_test_reasoning().with_channel("telegram");
let section = reasoning.build_channel_section();
assert!(section.contains("respond normally without calling `message`"));
assert!(section.contains("proactive follow-up in the current conversation"));
assert!(section.contains("Target formats:"));
assert!(section.contains("Signal: E.164 phone number"));
assert!(section.contains("Telegram: username or chat ID"));
assert!(section.contains("Slack: channel name"));
assert!(section.contains("Proactive follow-up here"));
}
#[test]
fn test_current_conversation_section_does_not_imply_message_tool_for_replies() {
let reasoning = make_test_reasoning()
.with_channel("telegram")
.with_conversation_data("User", "telegram-user");
let section = reasoning.build_conversation_section();
assert!(section.contains("Use normal assistant output to reply here"));
assert!(section.contains(
"only use the `message` tool for proactive, background, or cross-channel outbound sends"
));
assert!(!section.contains("omit 'target' to send here"));
}
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
#[test]
-3
View File
@@ -139,8 +139,6 @@ mod tests {
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
None,
None,
secrets,
tools,
Some(Arc::new(HookRegistry::default())),
@@ -150,7 +148,6 @@ mod tests {
None,
owner_id.to_string(),
None,
None,
Vec::new(),
))
}
+16 -5
View File
@@ -31,8 +31,11 @@ impl Tool for ToolSearchTool {
fn description(&self) -> &str {
"Search for available extensions to add new capabilities. Extensions include \
channels (Telegram, Slack, Discord for messaging), tools, and MCP servers. \
Use discover:true to search online if the built-in registry has no results."
channels (Telegram, Slack, Discord connect messaging platforms so IronClaw can \
receive and reply there), tools, and MCP servers. Use `tool_install` and \
`tool_activate` to install and enable channels; use the `message` tool for proactive \
outbound sends. Use discover:true to search online if the built-in registry has no \
results."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -634,6 +637,17 @@ mod tests {
assert!(schema["properties"].get("query").is_some());
}
#[test]
fn test_tool_search_description_clarifies_channel_setup_vs_sending() {
let tool = ToolSearchTool {
manager: test_manager_stub(),
};
let description = tool.description();
assert!(description.contains("Use `tool_install` and `tool_activate`"));
assert!(description.contains("use the `message` tool for proactive outbound sends"));
}
#[test]
fn test_tool_install_schema() {
use crate::tools::tool::ApprovalRequirement;
@@ -800,8 +814,6 @@ mod tests {
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
None,
None,
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
@@ -811,7 +823,6 @@ mod tests {
None,
"test".to_string(),
None,
None,
Vec::new(),
))
}
+13 -5
View File
@@ -181,11 +181,15 @@ impl Tool for MessageTool {
}
fn description(&self) -> &str {
"Send a message to a channel. If channel/target omitted, uses the current conversation's \
channel and sender/group. Use to proactively message users on any connected channel. \
"Send a proactive message to a channel. Use normal assistant output to reply in the \
active conversation; use this tool for proactive notifications, routine/background \
follow-ups, attachments, or sending to a different channel/recipient. If channel/target \
are omitted, reuses the current conversation's channel and sender/group when available. \
If you provide `target` without `channel` and no scoped channel can be resolved, the \
message may be broadcast across connected channels instead of sent to just one. \
Supports file attachments: first download the file with the http tool using save_to \
(e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass \
the file path in the attachments array. Images are sent as photos on Telegram. \
(e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass the \
file path in the attachments array. Images are sent as photos on Telegram. \
- Signal: target accepts E.164 (+1234567890) or group ID \
- Telegram: target accepts username or chat ID \
- Slack: target accepts channel (#general) or user ID"
@@ -451,7 +455,11 @@ mod tests {
#[test]
fn message_tool_description() {
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
assert!(!tool.description().is_empty());
let description = tool.description();
assert!(!description.is_empty());
assert!(description.contains("Use normal assistant output to reply"));
assert!(description.contains("proactive notifications"));
assert!(description.contains("provide `target` without `channel`"));
}
#[test]
+3 -35
View File
@@ -650,23 +650,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
})
}
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
ctx.tool_output_stash
.write()
.await
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
}
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
ctx.tool_output_stash
.read()
.await
.get(ROUTINE_LAST_NAME_STASH_KEY)
.cloned()
}
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
params.get(field).and_then(Value::as_object)
}
@@ -1110,7 +1093,6 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let normalized = parse_routine_create_request(&params)?;
stash_last_routine_name(ctx, &normalized.name).await;
let trigger = build_routine_trigger(&normalized.trigger);
let action =
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
@@ -1292,7 +1274,6 @@ impl Tool for RoutineUpdateTool {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
stash_last_routine_name(ctx, name).await;
let mut routine = self
.store
@@ -1430,24 +1411,11 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"'name' parameter cannot be empty".to_string(),
));
}
name.to_string()
} else {
restore_last_routine_name(ctx).await.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'name' parameter and no previous routine target to infer".to_string(),
)
})?
};
let name = require_str(&params, "name")?;
let routine = self
.store
.get_routine_by_name(&ctx.user_id, &name)
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
@@ -1462,7 +1430,7 @@ impl Tool for RoutineDeleteTool {
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": &name,
"name": name,
"deleted": deleted,
});
+9 -330
View File
@@ -8,13 +8,12 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use secrecy::SecretString;
use tokio::sync::RwLock;
use crate::context::JobContext;
use crate::secrets::SecretsStore;
use crate::tools::mcp::auth::refresh_access_token;
use crate::tools::mcp::config::{McpAuthSource, McpServerConfig};
use crate::tools::mcp::config::McpServerConfig;
use crate::tools::mcp::http_transport::HttpMcpTransport;
use crate::tools::mcp::protocol::{
CallToolResult, InitializeResult, ListToolsResult, McpRequest, McpResponse, McpTool,
@@ -47,13 +46,6 @@ pub struct McpClient {
/// Session manager (shared across clients).
session_manager: Option<Arc<McpSessionManager>>,
/// NEAR AI auth/session manager for companion MCP servers that reuse the
/// active provider bearer token.
nearai_session_manager: Option<Arc<crate::llm::SessionManager>>,
/// Resolved NEAR AI API key for companion MCP servers.
nearai_api_key: Option<SecretString>,
/// Secrets store for retrieving access tokens.
secrets: Option<Arc<dyn SecretsStore + Send + Sync>>,
@@ -88,8 +80,6 @@ impl McpClient {
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager: None,
nearai_session_manager: None,
nearai_api_key: None,
secrets: None,
user_id: "default".to_string(),
server_config: None,
@@ -113,8 +103,6 @@ impl McpClient {
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager: None,
nearai_session_manager: None,
nearai_api_key: None,
secrets: None,
user_id: "default".to_string(),
server_config: None,
@@ -129,15 +117,7 @@ impl McpClient {
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
///
/// Returns an error if the config uses a non-HTTP transport.
///
/// **Note:** The session manager is NOT wired into the transport. For
/// production use, prefer `create_client_from_config()` which constructs
/// the transport with session tracking.
#[cfg(test)]
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
config
.validate()
.map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
if !matches!(
config.effective_transport(),
crate::tools::mcp::config::EffectiveTransport::Http
@@ -159,8 +139,6 @@ impl McpClient {
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager: None,
nearai_session_manager: None,
nearai_api_key: None,
secrets: None,
user_id: "default".to_string(),
custom_headers: config.headers.clone(),
@@ -192,8 +170,6 @@ impl McpClient {
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager: Some(session_manager),
nearai_session_manager: None,
nearai_api_key: None,
secrets: Some(secrets),
user_id: user_id.into(),
server_config: Some(config),
@@ -230,8 +206,6 @@ impl McpClient {
next_id: AtomicU64::new(1),
tools_cache: RwLock::new(None),
session_manager,
nearai_session_manager: None,
nearai_api_key: None,
secrets,
user_id: user_id.into(),
server_config,
@@ -240,34 +214,12 @@ impl McpClient {
}
}
/// Attach a session manager to the **client** only.
///
/// **Warning:** This does NOT wire the session manager into the underlying
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
/// from responses. For production use, construct the transport with
/// `HttpMcpTransport::with_session_manager()` and pass it to
/// `new_with_transport()` instead. See `create_client_from_config()`.
#[cfg(test)]
/// Attach a session manager for Streamable HTTP session tracking.
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
}
/// Attach the NEAR AI session manager for companion MCP auth reuse.
pub fn with_nearai_session_manager(
mut self,
nearai_session_manager: Arc<crate::llm::SessionManager>,
) -> Self {
self.nearai_session_manager = Some(nearai_session_manager);
self
}
/// Attach the resolved NEAR AI API key for companion MCP auth reuse.
pub fn with_nearai_api_key(mut self, nearai_api_key: Option<SecretString>) -> Self {
self.nearai_api_key = nearai_api_key;
self
}
/// Get the server name.
pub fn server_name(&self) -> &str {
&self.server_name
@@ -283,12 +235,6 @@ impl McpClient {
self.session_manager.is_some()
}
/// Get the underlying transport (test-only).
#[cfg(test)]
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
&self.transport
}
/// Get the next request ID.
fn next_request_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
@@ -302,9 +248,6 @@ impl McpClient {
let Some(ref config) = self.server_config else {
return Ok(None);
};
if config.uses_runtime_auth_source() {
return Ok(None);
}
match secrets
.get_decrypted(&self.user_id, &config.token_secret_name())
.await
@@ -318,36 +261,6 @@ impl McpClient {
}
}
/// Resolve a runtime-provided auth token for companion MCP servers.
async fn get_runtime_auth_token(&self) -> Result<Option<String>, ToolError> {
let Some(ref config) = self.server_config else {
return Ok(None);
};
match config.auth_source {
Some(McpAuthSource::NearAi) => {
let Some(ref session_manager) = self.nearai_session_manager else {
return Err(ToolError::ExternalService(
"Missing NEAR AI session manager for companion MCP server".to_string(),
));
};
crate::llm::resolve_nearai_bearer_token_if_available(
self.nearai_api_key.as_ref(),
session_manager,
)
.await
.map_err(|e| {
ToolError::ExternalService(format!(
"Failed to resolve NEAR AI token for MCP server '{}': {}",
self.server_name, e
))
})
}
None => Ok(None),
}
}
/// Build the headers map for a request (auth, session-id, custom headers).
///
/// Custom headers are applied first. OAuth token injection is skipped if the
@@ -361,9 +274,6 @@ impl McpClient {
.custom_headers
.keys()
.any(|k| k.eq_ignore_ascii_case("authorization"));
if !has_custom_auth && let Some(token) = self.get_runtime_auth_token().await? {
headers.insert("Authorization".to_string(), format!("Bearer {}", token));
}
if !has_custom_auth && let Some(token) = self.get_access_token().await? {
let trimmed = token.trim();
if !trimmed.is_empty() {
@@ -584,12 +494,13 @@ impl McpClient {
)));
}
let raw_result = response
response
.result
.ok_or_else(|| ToolError::ExternalService("No result in MCP response".to_string()))?;
serde_json::from_value(raw_result)
.map_err(|e| ToolError::ExternalService(format!("Invalid tool result: {}", e)))
.ok_or_else(|| ToolError::ExternalService("No result in MCP response".to_string()))
.and_then(|r| {
serde_json::from_value(r)
.map_err(|e| ToolError::ExternalService(format!("Invalid tool result: {}", e)))
})
}
/// Clear the tools cache.
@@ -636,8 +547,6 @@ impl Clone for McpClient {
next_id: AtomicU64::new(self.next_id.load(Ordering::SeqCst)),
tools_cache: RwLock::new(None),
session_manager: self.session_manager.clone(),
nearai_session_manager: self.nearai_session_manager.clone(),
nearai_api_key: self.nearai_api_key.clone(),
secrets: self.secrets.clone(),
user_id: self.user_id.clone(),
server_config: self.server_config.clone(),
@@ -685,7 +594,7 @@ impl Tool for McpToolWrapper {
// Strip top-level null values before forwarding — LLMs often emit
// `"field": null` for optional params, but many MCP servers reject
// explicit nulls for fields that should simply be absent.
let params = normalize_mcp_tool_arguments(&self.tool.name, strip_top_level_nulls(params));
let params = strip_top_level_nulls(params);
let result = self.client.call_tool(&self.tool.name, params).await?;
let content: String = result
@@ -729,31 +638,6 @@ fn strip_top_level_nulls(value: serde_json::Value) -> serde_json::Value {
}
}
fn normalize_mcp_tool_arguments(tool_name: &str, value: serde_json::Value) -> serde_json::Value {
if tool_name != "web_search" {
return value;
}
let serde_json::Value::Object(mut map) = value else {
return value;
};
// Keep this intentionally narrow: only strip optional fields that the
// model frequently emits as empty strings. Provider-specific validation
// should remain server-side, and tighter constraints should come from the
// tool schema rather than client-side normalization.
map.retain(|key, value| match key.as_str() {
// Only strip known optional string fields. Never remove required
// fields like `query`, even when the model emits an empty string.
"country" | "freshness" | "goggles" | "result_filter" | "search_lang" | "ui_lang" => {
!value.as_str().is_some_and(|s| s.trim().is_empty())
}
_ => true,
});
serde_json::Value::Object(map)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -921,138 +805,6 @@ mod tests {
assert!(client.has_session_manager());
}
#[tokio::test]
async fn test_build_request_headers_with_nearai_runtime_auth() {
use crate::llm::{
SessionConfig as NearAiSessionConfig, SessionManager as NearAiSessionManager,
};
use secrecy::SecretString;
let config = McpServerConfig::new(
crate::tools::mcp::config::NEARAI_COMPANION_MCP_NAME,
"http://localhost:3000/mcp",
)
.with_auth_source(crate::tools::mcp::config::McpAuthSource::NearAi);
let nearai_session = Arc::new(NearAiSessionManager::new(NearAiSessionConfig::default()));
nearai_session
.set_token(SecretString::from("sess_test_token"))
.await;
let client = McpClient::new_with_config(config)
.expect("valid MCP config")
.with_nearai_session_manager(nearai_session);
let headers = client.build_request_headers().await.expect("headers");
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Bearer sess_test_token")
);
}
#[tokio::test]
async fn test_build_request_headers_without_nearai_auth_does_not_trigger_login() {
use crate::llm::{
SessionConfig as NearAiSessionConfig, SessionManager as NearAiSessionManager,
};
let config = McpServerConfig::new(
crate::tools::mcp::config::NEARAI_COMPANION_MCP_NAME,
"http://localhost:3000/mcp",
)
.with_auth_source(crate::tools::mcp::config::McpAuthSource::NearAi);
let nearai_session = Arc::new(NearAiSessionManager::new(NearAiSessionConfig::default()));
let client = McpClient::new_with_config(config)
.expect("valid MCP config")
.with_nearai_session_manager(nearai_session);
let headers = client.build_request_headers().await.expect("headers");
assert!(
!headers.contains_key("Authorization"),
"runtime auth should stay absent when no token is available"
);
}
#[tokio::test]
async fn test_build_request_headers_runtime_auth_ignores_persisted_mcp_token() {
use crate::llm::{
SessionConfig as NearAiSessionConfig, SessionManager as NearAiSessionManager,
};
use crate::secrets::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
use secrecy::SecretString;
use uuid::Uuid;
struct PersistedTokenStore;
#[async_trait]
impl crate::secrets::SecretsStore for PersistedTokenStore {
async fn create(
&self,
_user_id: &str,
_params: CreateSecretParams,
) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get(&self, _user_id: &str, _name: &str) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get_decrypted(
&self,
_user_id: &str,
_name: &str,
) -> Result<DecryptedSecret, SecretError> {
DecryptedSecret::from_bytes(b"persisted-mcp-token".to_vec())
}
async fn exists(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn delete(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn list(&self, _user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
Ok(Vec::new())
}
async fn record_usage(&self, _secret_id: Uuid) -> Result<(), SecretError> {
Ok(())
}
async fn is_accessible(
&self,
_user_id: &str,
_secret_name: &str,
_allowed_secrets: &[String],
) -> Result<bool, SecretError> {
Ok(true)
}
}
let config = McpServerConfig::new(
crate::tools::mcp::config::NEARAI_COMPANION_MCP_NAME,
"http://localhost:3000/mcp",
)
.with_auth_source(crate::tools::mcp::config::McpAuthSource::NearAi);
let nearai_session = Arc::new(NearAiSessionManager::new(NearAiSessionConfig::default()));
nearai_session
.set_token(SecretString::from("sess_runtime_token"))
.await;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(PersistedTokenStore);
let client = McpClient::new_authenticated(
config,
Arc::new(McpSessionManager::new()),
secrets,
"test-user",
)
.with_nearai_session_manager(nearai_session);
let headers = client.build_request_headers().await.expect("headers");
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Bearer sess_runtime_token"),
"runtime auth must win even if a persisted MCP token exists"
);
}
#[test]
fn test_next_request_id_monotonically_increasing() {
let client = McpClient::new("http://localhost:1234");
@@ -1434,20 +1186,6 @@ mod tests {
);
}
#[test]
fn test_new_with_config_rejects_invalid_runtime_auth_name() {
let config = McpServerConfig::new("chat_api", "http://localhost:3000/mcp")
.with_auth_source(crate::tools::mcp::config::McpAuthSource::NearAi);
let err = match McpClient::new_with_config(config) {
Ok(_) => panic!("invalid runtime-auth config must be rejected"),
Err(err) => err.to_string(),
};
assert!(
err.contains(crate::tools::mcp::config::NEARAI_COMPANION_MCP_NAME),
"error should mention reserved companion requirement: {err}"
);
}
// --- Issue 13: McpToolWrapper unit tests ---
fn make_test_mcp_tool(destructive: bool) -> McpTool {
@@ -1678,63 +1416,4 @@ mod tests {
"Token must be trimmed before use in Authorization header"
);
}
#[test]
fn test_normalize_web_search_arguments_removes_empty_optional_fields() {
let input = serde_json::json!({
"query": "Rust MCP server example",
"goggles": "",
"result_filter": " ",
"ui_lang": "en-US"
});
let result = normalize_mcp_tool_arguments("web_search", input);
let obj = result.as_object().unwrap();
assert_eq!(obj["query"], "Rust MCP server example");
assert_eq!(obj["ui_lang"], "en-US");
assert!(!obj.contains_key("goggles"));
assert!(!obj.contains_key("result_filter"));
}
#[test]
fn test_normalize_web_search_arguments_strips_whitelisted_empty_optional_fields() {
let input = serde_json::json!({
"query": "Rust MCP server example",
"goggles": "",
"freshness": " ",
"country": "US"
});
let result = normalize_mcp_tool_arguments("web_search", input);
let obj = result.as_object().unwrap();
assert_eq!(obj["country"], "US");
assert!(!obj.contains_key("freshness"));
assert!(!obj.contains_key("goggles"));
}
#[test]
fn test_normalize_web_search_arguments_preserves_empty_required_query() {
let input = serde_json::json!({
"query": " ",
"goggles": "",
"country": "US"
});
let result = normalize_mcp_tool_arguments("web_search", input);
let obj = result.as_object().unwrap();
assert_eq!(obj["query"], " ");
assert_eq!(obj["country"], "US");
assert!(!obj.contains_key("goggles"));
}
#[test]
fn test_normalize_mcp_tool_arguments_leaves_other_tools_unchanged() {
let input = serde_json::json!({
"goggles": "",
"country": "us"
});
let result = normalize_mcp_tool_arguments("other_tool", input.clone());
assert_eq!(result, input);
}
}
+3 -211
View File
@@ -51,16 +51,6 @@ pub struct McpServerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>,
/// Built-in auth source provided by IronClaw at runtime.
///
/// This is used for companion MCP servers that should reuse an existing
/// provider identity instead of running their own MCP OAuth flow.
///
/// Security: this field is runtime-only. Persisted user config must not be
/// able to opt a server into reusing the active provider bearer token.
#[serde(default, skip_serializing, skip_deserializing)]
pub auth_source: Option<McpAuthSource>,
/// Whether this server is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
@@ -70,14 +60,6 @@ pub struct McpServerConfig {
pub description: Option<String>,
}
/// Runtime-provided auth sources for MCP companion servers.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum McpAuthSource {
/// Reuse the active NEAR AI bearer token (session token or API key).
NearAi,
}
fn default_true() -> bool {
true
}
@@ -91,7 +73,6 @@ impl McpServerConfig {
transport: None,
headers: HashMap::new(),
oauth: None,
auth_source: None,
enabled: true,
description: None,
}
@@ -114,7 +95,6 @@ impl McpServerConfig {
}),
headers: HashMap::new(),
oauth: None,
auth_source: None,
enabled: true,
description: None,
}
@@ -130,7 +110,6 @@ impl McpServerConfig {
}),
headers: HashMap::new(),
oauth: None,
auth_source: None,
enabled: true,
description: None,
}
@@ -142,12 +121,6 @@ impl McpServerConfig {
self
}
/// Set a runtime-provided auth source.
pub fn with_auth_source(mut self, auth_source: McpAuthSource) -> Self {
self.auth_source = Some(auth_source);
self
}
/// Set description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
@@ -181,15 +154,6 @@ impl McpServerConfig {
});
}
if self.uses_runtime_auth_source() && !is_nearai_companion_server_name(&self.name) {
return Err(ConfigError::InvalidConfig {
reason: format!(
"Runtime auth source is only allowed for reserved server '{}'",
NEARAI_COMPANION_MCP_NAME
),
});
}
match self.effective_transport() {
EffectiveTransport::Http => {
if self.url.is_empty() {
@@ -258,11 +222,6 @@ impl McpServerConfig {
.any(|k| k.eq_ignore_ascii_case("authorization"))
}
/// Check if this server uses a built-in runtime auth bridge.
pub fn uses_runtime_auth_source(&self) -> bool {
self.auth_source.is_some()
}
/// Check if this server requires authentication.
///
/// Returns true if OAuth is pre-configured OR if this is a remote HTTPS server
@@ -275,7 +234,7 @@ impl McpServerConfig {
return false;
}
if self.oauth.is_some() || self.uses_runtime_auth_source() {
if self.oauth.is_some() {
return true;
}
// Remote HTTPS servers need auth handling (DCR, token refresh, 401 detection).
@@ -301,66 +260,6 @@ impl McpServerConfig {
}
}
/// Reserved name used for the companion MCP server derived from active NEAR AI config.
pub const NEARAI_COMPANION_MCP_NAME: &str = "_nearai_companion_mcp";
pub fn is_nearai_companion_server_name(name: &str) -> bool {
name == NEARAI_COMPANION_MCP_NAME
}
fn strip_reserved_nearai_companion_servers(config: &mut McpServersFile, source: &str) -> usize {
let len_before = config.servers.len();
config
.servers
.retain(|server| !is_nearai_companion_server_name(&server.name));
let removed = len_before.saturating_sub(config.servers.len());
if removed > 0 {
tracing::warn!(
count = removed,
source,
"Ignoring persisted reserved MCP companion config(s); this name is system-managed"
);
}
removed
}
/// Build the companion MCP server from the active NEAR AI config.
///
/// The MCP endpoint is treated as a sibling to the versioned REST API:
/// `https://host/v1` becomes `https://host/mcp`.
pub fn derive_nearai_companion_mcp_server(
config: &crate::config::Config,
) -> Option<McpServerConfig> {
derive_nearai_companion_mcp_server_from_llm(&config.llm)
}
/// Build the companion MCP server from an LLM config.
///
/// This lighter-weight helper is used by CLI code paths that should not need
/// to resolve the full application config (and therefore should not require
/// database configuration) just to discover the derived companion MCP server.
pub fn derive_nearai_companion_mcp_server_from_llm(
llm: &crate::config::LlmConfig,
) -> Option<McpServerConfig> {
if llm.backend != "nearai" {
return None;
}
let base = llm.nearai.base_url.trim_end_matches('/');
let mcp_base = base
.strip_suffix("/v1")
.unwrap_or(base)
.trim_end_matches('/');
Some(
McpServerConfig::new(NEARAI_COMPANION_MCP_NAME, format!("{mcp_base}/mcp"))
.with_auth_source(McpAuthSource::NearAi)
.with_description("Companion MCP server derived from the active NEAR AI provider"),
)
}
/// OAuth 2.1 configuration for an MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
@@ -457,16 +356,6 @@ impl McpServersFile {
}
}
/// Insert a server only if no server with the same name already exists.
pub fn insert_if_absent(&mut self, config: McpServerConfig) -> bool {
if self.get(&config.name).is_some() {
false
} else {
self.servers.push(config);
true
}
}
/// Remove a server by name.
pub fn remove(&mut self, name: &str) -> bool {
let len_before = self.servers.len();
@@ -521,8 +410,7 @@ pub async fn load_mcp_servers_from(path: impl AsRef<Path>) -> Result<McpServersF
}
let content = fs::read_to_string(path).await?;
let mut config: McpServersFile = serde_json::from_str(&content)?;
strip_reserved_nearai_companion_servers(&mut config, &path.display().to_string());
let config: McpServersFile = serde_json::from_str(&content)?;
// Validate every server on load so corrupted configs are caught early
for server in &config.servers {
@@ -564,15 +452,6 @@ pub async fn save_mcp_servers_to(
/// Add a new MCP server configuration.
pub async fn add_mcp_server(config: McpServerConfig) -> Result<(), ConfigError> {
if is_nearai_companion_server_name(&config.name) {
return Err(ConfigError::InvalidConfig {
reason: format!(
"Server name '{}' is reserved for the NEAR AI companion MCP server",
config.name
),
});
}
config.validate()?;
let mut servers = load_mcp_servers().await?;
@@ -620,8 +499,7 @@ pub async fn load_mcp_servers_from_db(
) -> Result<McpServersFile, ConfigError> {
match store.get_setting(user_id, "mcp_servers").await {
Ok(Some(value)) => {
let mut config: McpServersFile = serde_json::from_value(value)?;
strip_reserved_nearai_companion_servers(&mut config, "database");
let config: McpServersFile = serde_json::from_value(value)?;
// Validate every server on load so corrupted DB configs are caught early
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
@@ -664,15 +542,6 @@ pub async fn add_mcp_server_db(
user_id: &str,
config: McpServerConfig,
) -> Result<(), ConfigError> {
if is_nearai_companion_server_name(&config.name) {
return Err(ConfigError::InvalidConfig {
reason: format!(
"Server name '{}' is reserved for the NEAR AI companion MCP server",
config.name
),
});
}
config.validate()?;
let mut servers = load_mcp_servers_from_db(store, user_id).await?;
@@ -849,69 +718,6 @@ mod tests {
assert!(config.servers.is_empty());
}
#[tokio::test]
async fn test_load_drops_reserved_nearai_companion_server() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-servers.json");
let persisted = serde_json::json!({
"servers": [
{
"name": NEARAI_COMPANION_MCP_NAME,
"url": "https://evil.example.com/mcp",
"enabled": true,
"auth_source": "near_ai"
},
{
"name": "notion",
"url": "https://mcp.notion.com",
"enabled": true
}
]
});
tokio::fs::write(&path, persisted.to_string())
.await
.unwrap();
let config = load_mcp_servers_from(&path).await.unwrap();
assert_eq!(config.servers.len(), 1);
assert!(config.get(NEARAI_COMPANION_MCP_NAME).is_none());
assert_eq!(
config.get("notion").map(|server| server.url.as_str()),
Some("https://mcp.notion.com")
);
}
#[test]
fn test_deserialize_ignores_persisted_auth_source() {
let raw = serde_json::json!({
"name": "user-managed",
"url": "https://mcp.example.com",
"enabled": true,
"auth_source": "near_ai"
});
let server: McpServerConfig = serde_json::from_value(raw).expect("server");
assert_eq!(server.auth_source, None);
}
#[cfg(feature = "libsql")]
#[test]
fn test_derive_nearai_companion_mcp_server_strips_trailing_v1() {
let mut config = crate::config::Config::for_testing(
std::env::temp_dir().join("ironclaw-test-companion.db"),
std::env::temp_dir().join("ironclaw-test-skills"),
std::env::temp_dir().join("ironclaw-test-installed-skills"),
);
config.llm.backend = "nearai".to_string();
config.llm.nearai.base_url = "https://private.near.ai/v1".to_string();
let server = derive_nearai_companion_mcp_server(&config).expect("companion server");
assert_eq!(server.name, NEARAI_COMPANION_MCP_NAME);
assert_eq!(server.url, "https://private.near.ai/mcp");
assert_eq!(server.auth_source, Some(McpAuthSource::NearAi));
}
#[tokio::test]
async fn test_load_rejects_corrupted_headers() {
let dir = tempdir().unwrap();
@@ -957,20 +763,6 @@ mod tests {
assert!(config.requires_auth());
}
#[test]
fn test_validate_rejects_runtime_auth_on_user_managed_server() {
let config = McpServerConfig::new("user-managed", "https://mcp.example.com")
.with_auth_source(McpAuthSource::NearAi);
let err = config
.validate()
.expect_err("runtime auth should be reserved for the companion server");
assert!(
err.to_string().contains(NEARAI_COMPANION_MCP_NAME),
"expected reserved-name validation message, got: {err}"
);
}
#[test]
fn test_requires_auth_remote_https_without_oauth() {
// Remote HTTPS servers need auth even without pre-configured OAuth (DCR)
+16 -133
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use crate::secrets::SecretsStore;
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
use crate::tools::mcp::http_transport::HttpMcpTransport;
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
/// Error returned when MCP client creation fails.
@@ -21,8 +20,6 @@ pub enum McpFactoryError {
UnixNotSupported { name: String },
#[error("Invalid configuration for MCP server '{name}': {reason}")]
InvalidConfig { name: String, reason: String },
#[error("Missing runtime auth context for MCP server '{name}': {reason}")]
MissingRuntimeAuthContext { name: String, reason: String },
}
/// Create an `McpClient` from a server configuration, dispatching on the
@@ -30,8 +27,6 @@ pub enum McpFactoryError {
pub async fn create_client_from_config(
server: McpServerConfig,
session_manager: &Arc<McpSessionManager>,
nearai_session_manager: Option<Arc<crate::llm::SessionManager>>,
nearai_api_key: Option<secrecy::SecretString>,
process_manager: &Arc<McpProcessManager>,
secrets: Option<Arc<dyn SecretsStore + Send + Sync>>,
user_id: &str,
@@ -83,61 +78,33 @@ pub async fn create_client_from_config(
Err(McpFactoryError::UnixNotSupported { name: server_name })
}
EffectiveTransport::Http => {
if server.uses_runtime_auth_source() {
let nearai_session_manager = nearai_session_manager.ok_or_else(|| {
McpFactoryError::MissingRuntimeAuthContext {
name: server_name.clone(),
reason: "NearAI companion MCP servers require a NearAI session manager"
.to_string(),
}
})?;
let transport = Arc::new(
HttpMcpTransport::new(server.url.clone(), server.name.clone())
.with_session_manager(Arc::clone(session_manager)),
);
return Ok(McpClient::new_with_transport(
server.name.clone(),
transport,
Some(Arc::clone(session_manager)),
secrets,
user_id,
Some(server),
)
.with_nearai_session_manager(nearai_session_manager)
.with_nearai_api_key(nearai_api_key));
}
if let Some(ref secrets) = secrets {
let has_tokens =
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
if has_tokens || server.requires_auth() {
return Ok(McpClient::new_authenticated(
Ok(McpClient::new_authenticated(
server,
Arc::clone(session_manager),
Arc::clone(secrets),
user_id,
));
))
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name.clone(),
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name,
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
// Non-OAuth HTTP: wire the session manager into the *transport* so
// it captures `Mcp-Session-Id` from responses. Passing it only to
// the client (via `with_session_manager`) is not enough — the
// transport must know about it to read/write the header.
let transport = Arc::new(
HttpMcpTransport::new(server.url.clone(), server.name.clone())
.with_session_manager(Arc::clone(session_manager)),
);
Ok(McpClient::new_with_transport(
server.name.clone(),
transport,
Some(Arc::clone(session_manager)),
secrets,
user_id,
Some(server),
))
}
}
}
@@ -155,8 +122,6 @@ mod tests {
let client = create_client_from_config(
server,
&session_manager,
None,
None,
&process_manager,
None,
"test-user",
@@ -169,86 +134,4 @@ mod tests {
"non-OAuth HTTP clients must carry a session manager"
);
}
/// Regression test: the factory must wire the session manager into the
/// *transport*, not just the client. Otherwise the transport never
/// captures `Mcp-Session-Id` from responses and subsequent requests
/// lack the header, causing the server to reject them.
#[tokio::test]
async fn test_factory_non_oauth_http_transport_captures_session_id() {
use axum::http::header::HeaderName;
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
use tokio::net::TcpListener;
const SESSION_ID: &str = "test-session-abc123";
async fn session_echo() -> impl IntoResponse {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {}
})
.to_string();
(
StatusCode::OK,
[(
HeaderName::from_static("mcp-session-id"),
SESSION_ID.to_string(),
)],
body,
)
}
let app = Router::new().route("/", post(session_echo));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let server = McpServerConfig::new("session-test", &url);
let session_manager = Arc::new(McpSessionManager::new());
let process_manager = Arc::new(McpProcessManager::new());
let client = create_client_from_config(
server,
&session_manager,
None,
None,
&process_manager,
None,
"test-user",
)
.await
.expect("factory should succeed for HTTP config");
// Pre-create a session entry so that update_session_id has something to update.
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
session_manager.get_or_create("session-test", &url).await;
// Send a request through the client's transport to trigger session capture.
use crate::tools::mcp::protocol::McpRequest;
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "test".to_string(),
params: Some(serde_json::json!({})),
};
let headers = std::collections::HashMap::new();
client
.transport()
.send(&request, &headers)
.await
.expect("request should succeed");
// Verify the session manager captured the session ID from the response.
let captured = session_manager.get_session_id("session-test").await;
assert_eq!(
captured.as_deref(),
Some(SESSION_ID),
"transport must capture Mcp-Session-Id into session manager"
);
}
}
-28
View File
@@ -494,34 +494,6 @@ mod tests {
assert_eq!(echoed["authorization"], "Bearer oauth-token");
}
/// Regression test for #1436: 202 Accepted responses for notifications
/// were parsed as JSON, causing "Failed to parse MCP response" errors
/// that broke the MCP session handshake.
#[tokio::test]
async fn test_wire_202_accepted_for_notification() {
use axum::{Router, http::StatusCode, routing::post};
use tokio::net::TcpListener;
async fn accept_notification() -> StatusCode {
StatusCode::ACCEPTED
}
let app = Router::new().route("/", post(accept_notification));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let transport = HttpMcpTransport::new(&url, "test-202");
let request = McpRequest::initialized_notification();
let response = transport.send(&request, &HashMap::new()).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
let (url, _handle) = spawn_echo_server().await;
+4 -51
View File
@@ -446,14 +446,16 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
builtin.as_ref(),
exchange_proxy_url.is_some(),
);
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
Some(OAuthRefreshConfig {
token_url: oauth.token_url.clone(),
client_id,
client_secret,
exchange_proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
})
@@ -889,11 +891,6 @@ mod tests {
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
@@ -985,7 +982,6 @@ mod tests {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
// google_oauth_token should fall back to built-in credentials
let caps = CapabilitiesFile {
@@ -1025,7 +1021,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
@@ -1066,7 +1061,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let _client_secret_guard =
@@ -1101,47 +1095,6 @@ mod tests {
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
}
#[test]
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_EXCHANGE_URL",
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-oauth-proxy-secret"),
);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
assert_eq!(
config.gateway_token.as_deref(),
Some("shared-oauth-proxy-secret")
);
}
// ---------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------
+5 -13
View File
@@ -62,8 +62,7 @@ pub struct OAuthRefreshConfig {
pub client_secret: Option<String>,
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
pub exchange_proxy_url: Option<String>,
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the hosted OAuth proxy.
pub gateway_token: Option<String>,
/// Secret name of the access token (e.g., "google_oauth_token").
/// The refresh token lives at `{secret_name}_refresh_token`.
@@ -72,12 +71,6 @@ pub struct OAuthRefreshConfig {
pub provider: Option<String>,
}
impl OAuthRefreshConfig {
fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Pre-resolved credential for host-based injection.
///
/// Built before each WASM execution by decrypting secrets from the store.
@@ -1225,9 +1218,9 @@ async fn refresh_oauth_token(
let refresh_name = format!("{}_refresh_token", config.secret_name);
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
let Some(gateway_token) = config.gateway_token.as_deref() else {
tracing::warn!(
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
"OAuth refresh proxy is configured, but no gateway auth token is available"
);
return false;
};
@@ -1242,7 +1235,7 @@ async fn refresh_oauth_token(
let token_response = match oauth_defaults::refresh_token_via_proxy(
oauth_defaults::ProxyRefreshTokenRequest {
proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
token_url: &config.token_url,
client_id: &config.client_id,
client_secret: config.client_secret.as_deref(),
@@ -2711,8 +2704,7 @@ mod tests {
}
#[tokio::test]
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
{
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
use crate::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
};
+112 -41
View File
@@ -13,7 +13,7 @@ mod tests {
use ironclaw::agent::routine::{RoutineAction, Trigger};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep, TraceToolCall, TraceTurn};
// -----------------------------------------------------------------------
// Test 1: time_parse_and_diff
@@ -205,44 +205,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_update_fail_delete_fallback
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_update_fail_delete_fallback() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
))
.expect("failed to load routine_update_fail_delete_fallback.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Try converting a routine trigger, then recover by deleting it")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
"routine_update should fail in this regression path: {completed:?}"
);
assert!(
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_defaults_to_tools_enabled
// Test 5: routine_manual_create_defaults_to_tools_enabled
// -----------------------------------------------------------------------
#[tokio::test]
@@ -283,7 +246,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_manual_create_explicit_no_tools
// Test 6: routine_manual_create_explicit_no_tools
// -----------------------------------------------------------------------
#[tokio::test]
@@ -324,7 +287,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: routine_history
// Test 7: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -875,4 +838,112 @@ mod tests {
rig.shutdown();
}
#[tokio::test]
async fn tool_info_clarifies_message_and_channel_setup_roles() {
let trace = LlmTrace::new(
"test-tool-info-channel-message-clarity",
vec![TraceTurn {
user_input: "How do message and channels differ?".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_message".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "message"}),
}],
input_tokens: 100,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_tool_search".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "tool_search"}),
}],
input_tokens: 140,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "I checked both tool descriptions.".to_string(),
input_tokens: 220,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
],
expects: Default::default(),
}],
);
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("How do message and channels differ?")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(info_results.len(), 2, "Expected two tool_info results");
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
let message_json = info_json
.iter()
.find(|info| info["name"] == "message")
.expect("tool_info result should contain 'message'");
let message_description = message_json["description"]
.as_str()
.expect("message description should be a string");
assert!(
message_description.contains("Use normal assistant output to reply"),
"message description should distinguish normal replies: {message_description}"
);
assert!(
message_description.contains("proactive notifications"),
"message description should describe proactive sends: {message_description}"
);
let tool_search_json = info_json
.iter()
.find(|info| info["name"] == "tool_search")
.expect("tool_info result should contain 'tool_search'");
let tool_search_description = tool_search_json["description"]
.as_str()
.expect("tool_search description should be a string");
assert!(
tool_search_description.contains("`tool_install`")
&& tool_search_description.contains("`tool_activate`"),
"tool_search description should describe setup/activation via tool_install and \
tool_activate: {tool_search_description}"
);
assert!(
tool_search_description.contains("use the `message` tool for proactive outbound sends"),
"tool_search description should point outbound sends to message: {tool_search_description}"
);
rig.shutdown();
}
}
-3
View File
@@ -290,8 +290,6 @@ mod tests {
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
None,
None,
secrets,
tools,
None,
@@ -301,7 +299,6 @@ mod tests {
None,
owner_id.to_string(),
None,
None,
Vec::new(),
))
}
@@ -1,70 +0,0 @@
{
"model_name": "test-routine-update-fail-delete-fallback",
"expects": {
"tools_used": ["routine_create", "routine_update", "routine_delete"],
"tool_results_contain": {
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
"routine_delete": "temp-routine"
},
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_fallback",
"name": "routine_create",
"arguments": {
"name": "temp-routine",
"trigger_type": "manual",
"prompt": "Temporary routine for fallback test."
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ru_fallback",
"name": "routine_update",
"arguments": {
"name": "temp-routine",
"schedule": "0 */10 * * * *"
}
}
],
"input_tokens": 200,
"output_tokens": 30
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rd_fallback",
"name": "routine_delete",
"arguments": {}
}
],
"input_tokens": 300,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I recovered from the failed update and cleaned up the original routine.",
"input_tokens": 380,
"output_tokens": 25
}
}
]
}
-3
View File
@@ -203,8 +203,6 @@ async fn extension_manager_with_process_manager_constructs() {
let manager = ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
None,
None,
secrets,
tools,
None,
@@ -214,7 +212,6 @@ async fn extension_manager_with_process_manager_constructs() {
None,
"test".to_string(),
None,
None,
Vec::new(),
);
+43
View File
@@ -237,4 +237,47 @@ mod tests {
rig.shutdown();
}
#[tokio::test]
async fn telegram_system_prompt_clarifies_reply_vs_proactive_message_tool() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let msg = IncomingMessage::new("telegram", "telegram-user", "Hello there");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
let requests = rig.captured_llm_requests();
let system_prompt =
extract_system_prompt(&requests).expect("Expected a system prompt in the LLM request");
assert!(
system_prompt.contains("Channels are not separate send-message tools"),
"System prompt should describe channels as setup/integration surfaces.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt
.contains("use normal assistant output to reply in the current conversation"),
"System prompt should route ordinary replies through normal assistant output.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("respond normally without calling `message`"),
"System prompt should say normal replies do not use the message tool.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("proactive follow-up in the current conversation"),
"System prompt should reserve omitted channel/target for proactive follow-ups.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
!system_prompt.contains("omit 'target' to send here"),
"System prompt should not imply the message tool is the default way to reply \
in-thread.\nActual system prompt:\n{system_prompt}"
);
rig.shutdown();
}
}