mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e1a41073 | ||
|
|
2508628a36 | ||
|
|
ca6beb87bc | ||
|
|
b5204372a3 | ||
|
|
0b66aa6f8a | ||
|
|
e1ddf37cae | ||
|
|
993f2b25dc |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -2201,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(),
|
||||
@@ -2213,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).
|
||||
@@ -2235,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())));
|
||||
}
|
||||
@@ -2249,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) => {
|
||||
|
||||
+1
-2
@@ -127,8 +127,7 @@ async fn list_settings(
|
||||
}
|
||||
|
||||
let display_value = if value.len() > 60 {
|
||||
let end = crate::util::floor_char_boundary(&value, 57);
|
||||
format!("{}...", &value[..end])
|
||||
format!("{}...", &value[..57])
|
||||
} else {
|
||||
value
|
||||
};
|
||||
|
||||
+1
-15
@@ -256,8 +256,7 @@ fn truncate_content(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
let end = crate::util::floor_char_boundary(s, max_len);
|
||||
format!("{}...", &s[..end])
|
||||
format!("{}...", &s[..max_len])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,17 +292,4 @@ mod tests {
|
||||
assert_eq!(truncate_content("hello", 10), "hello");
|
||||
assert_eq!(truncate_content("hello world", 5), "hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_content_multibyte_does_not_panic() {
|
||||
// \u{00e9} is precomposed 'é' (2 bytes in UTF-8)
|
||||
let s = "caf\u{00e9} au lait"; // "café au lait", é starts at byte 3
|
||||
let result = truncate_content(s, 4); // byte 4 is inside 2-byte é
|
||||
assert_eq!(result, "caf...");
|
||||
|
||||
// 4-byte emoji: slicing mid-emoji must not panic
|
||||
let emoji = "Hi \u{1F600} there"; // 😀 is 4 bytes, starts at byte 3
|
||||
let result = truncate_content(emoji, 4); // byte 4 is inside 😀
|
||||
assert_eq!(result, "Hi ...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
+30
-389
@@ -659,66 +659,6 @@ impl ExtensionManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the relay URL override for an extension from settings.
|
||||
///
|
||||
/// Returns `Some(url)` if a non-empty per-extension `relay_url` override is
|
||||
/// set for the given extension; otherwise returns `None` and callers should
|
||||
/// fall back to the env-level `RelayConfig`.
|
||||
///
|
||||
/// Uses `self.user_id` (owner scope) for consistency with `configure()`,
|
||||
/// which also writes setting_path fields under the owner scope.
|
||||
///
|
||||
/// The override is validated: only `http` / `https` schemes are accepted
|
||||
/// and the URL must not contain userinfo (embedded credentials). This
|
||||
/// prevents a malicious override from exfiltrating the instance-wide relay
|
||||
/// API key to an attacker-controlled host.
|
||||
async fn effective_relay_url(&self, name: &str) -> Option<String> {
|
||||
if let Some(ref store) = self.store {
|
||||
let key = format!("extensions.{name}.relay_url");
|
||||
if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await {
|
||||
let url = v
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(ref u) = url {
|
||||
// Validate the override to prevent API-key exfiltration:
|
||||
// only allow http(s) with no embedded credentials.
|
||||
match url::Url::parse(u) {
|
||||
Ok(parsed)
|
||||
if (parsed.scheme() == "http" || parsed.scheme() == "https")
|
||||
&& parsed.username().is_empty()
|
||||
&& parsed.password().is_none() =>
|
||||
{
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url_host = %parsed.host_str().unwrap_or("unknown"),
|
||||
"effective_relay_url: using per-extension override from settings"
|
||||
);
|
||||
return url;
|
||||
}
|
||||
Ok(parsed) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
scheme = %parsed.scheme(),
|
||||
has_userinfo = !parsed.username().is_empty() || parsed.password().is_some(),
|
||||
"effective_relay_url: rejecting override — \
|
||||
only http/https without embedded credentials is allowed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"effective_relay_url: rejecting override — invalid URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the shared relay event sender for the webhook endpoint.
|
||||
pub fn relay_event_tx(
|
||||
&self,
|
||||
@@ -952,46 +892,6 @@ impl ExtensionManager {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether a stored `team_id` setting exists for the given relay extension.
|
||||
///
|
||||
/// Unlike [`is_relay_channel`], this does **not** consult the in-memory
|
||||
/// `installed_relay_extensions` set — it only looks at the persistent settings
|
||||
/// store. This distinction matters for `auth_channel_relay`: an extension can
|
||||
/// be *installed* (present in the in-memory set) but not yet *authenticated*
|
||||
/// (no OAuth completed, no team_id stored).
|
||||
async fn has_stored_team_id(&self, name: &str, _user_id: &str) -> bool {
|
||||
if let Some(ref store) = self.store {
|
||||
let key = format!("relay:{}:team_id", name);
|
||||
// Use owner scope (self.user_id) for consistency: the OAuth callback
|
||||
// stores team_id under state.owner_id which maps to self.user_id.
|
||||
match store.get_setting(&self.user_id, &key).await {
|
||||
Ok(Some(v)) => {
|
||||
let has_id = v.as_str().is_some_and(|s| !s.is_empty());
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
has_team_id = has_id,
|
||||
"has_stored_team_id: checked store"
|
||||
);
|
||||
return has_id;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"has_stored_team_id: no team_id setting found"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"has_stored_team_id: failed to read from settings store"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Restore persisted relay channels after startup.
|
||||
///
|
||||
/// Loads the persisted active channel list, filters to relay types (those with
|
||||
@@ -1518,7 +1418,7 @@ impl ExtensionManager {
|
||||
let errors = self.activation_errors.read().await;
|
||||
for name in installed.iter() {
|
||||
let active = active_names.contains(name);
|
||||
let authenticated = self.has_stored_team_id(name, user_id).await;
|
||||
let authenticated = self.is_relay_channel(name, user_id).await;
|
||||
let activation_error = errors.get(name).cloned();
|
||||
let registry_entry = self
|
||||
.registry
|
||||
@@ -4291,69 +4191,20 @@ impl ExtensionManager {
|
||||
name: &str,
|
||||
user_id: &str,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
user_id = %user_id,
|
||||
"auth_channel_relay: starting"
|
||||
);
|
||||
|
||||
// Check if already authenticated by looking for a stored team_id.
|
||||
// We intentionally skip the `installed_relay_extensions` in-memory set
|
||||
// here because that set only tracks *installed* extensions — an extension
|
||||
// can be installed (via registry) but not yet authenticated (no OAuth
|
||||
// completed). Checking just `is_relay_channel()` would short-circuit
|
||||
// to "authenticated" even when no team_id exists, preventing the OAuth
|
||||
// flow from being offered to the user.
|
||||
if self.has_stored_team_id(name, user_id).await {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: already authenticated (team_id in store)"
|
||||
);
|
||||
// Check if already authenticated (team_id setting exists)
|
||||
if self.is_relay_channel(name, user_id).await {
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::ChannelRelay));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: no stored team_id, initiating OAuth"
|
||||
);
|
||||
|
||||
// Use relay config captured at startup
|
||||
let relay_config = self.relay_config().map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"auth_channel_relay: relay config not available — \
|
||||
CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Allow per-extension URL override from settings
|
||||
let effective_url = self
|
||||
.effective_relay_url(name)
|
||||
.await
|
||||
.unwrap_or_else(|| relay_config.url.clone());
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"auth_channel_relay: creating relay client for OAuth"
|
||||
);
|
||||
let relay_config = self.relay_config()?;
|
||||
|
||||
let client = crate::channels::relay::RelayClient::new(
|
||||
effective_url.clone(),
|
||||
relay_config.url.clone(),
|
||||
relay_config.api_key.clone(),
|
||||
relay_config.request_timeout_secs,
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"auth_channel_relay: failed to create relay HTTP client"
|
||||
);
|
||||
ExtensionError::Config(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||
|
||||
// Generate CSRF nonce — IronClaw validates this on the callback to ensure
|
||||
// the OAuth completion is legitimate. Channel-relay embeds it in the signed
|
||||
@@ -4365,44 +4216,18 @@ impl ExtensionManager {
|
||||
self.secrets
|
||||
.create(user_id, CreateSecretParams::new(&state_key, &state_nonce))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"auth_channel_relay: failed to store OAuth state nonce"
|
||||
);
|
||||
ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}"))
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?;
|
||||
|
||||
// Channel-relay derives all URLs from trusted instance_url in chat-api.
|
||||
// We only pass the nonce for CSRF validation on the callback.
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"auth_channel_relay: calling initiate_oauth on channel-relay"
|
||||
);
|
||||
match client.initiate_oauth(Some(&state_nonce)).await {
|
||||
Ok(auth_url) => {
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: OAuth URL obtained, awaiting user authorization"
|
||||
);
|
||||
Ok(AuthResult::awaiting_authorization(
|
||||
name,
|
||||
ExtensionKind::ChannelRelay,
|
||||
auth_url,
|
||||
"redirect".to_string(),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"auth_channel_relay: initiate_oauth call to channel-relay failed"
|
||||
);
|
||||
Err(ExtensionError::AuthFailed(e.to_string()))
|
||||
}
|
||||
Ok(auth_url) => Ok(AuthResult::awaiting_authorization(
|
||||
name,
|
||||
ExtensionKind::ChannelRelay,
|
||||
auth_url,
|
||||
"redirect".to_string(),
|
||||
)),
|
||||
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4412,112 +4237,40 @@ impl ExtensionManager {
|
||||
name: &str,
|
||||
user_id: &str,
|
||||
) -> Result<ActivateResult, ExtensionError> {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
user_id = %user_id,
|
||||
"activate_channel_relay: starting"
|
||||
);
|
||||
|
||||
let team_id_key = format!("relay:{}:team_id", name);
|
||||
|
||||
// Get team_id from settings (stored by the OAuth callback)
|
||||
let team_id = if let Some(ref store) = self.store {
|
||||
match store.get_setting(user_id, &team_id_key).await {
|
||||
Ok(Some(v)) => {
|
||||
let id = v.as_str().map(|s| s.to_string()).unwrap_or_default();
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
team_id_empty = id.is_empty(),
|
||||
"activate_channel_relay: loaded team_id from store"
|
||||
);
|
||||
id
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
setting_key = %team_id_key,
|
||||
"activate_channel_relay: no team_id in settings store"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to read team_id from settings store"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
store
|
||||
.get_setting(user_id, &team_id_key)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: no settings store available"
|
||||
);
|
||||
String::new()
|
||||
};
|
||||
|
||||
if team_id.is_empty() {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: team_id is empty, returning AuthRequired"
|
||||
);
|
||||
return Err(ExtensionError::AuthRequired);
|
||||
}
|
||||
|
||||
// Use relay config captured at startup
|
||||
let relay_config = self.relay_config().map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: relay config not available"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Allow per-extension URL override from settings
|
||||
let effective_url = self
|
||||
.effective_relay_url(name)
|
||||
.await
|
||||
.unwrap_or_else(|| relay_config.url.clone());
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"activate_channel_relay: relay config loaded"
|
||||
);
|
||||
let relay_config = self.relay_config()?;
|
||||
|
||||
let instance_id = self.relay_instance_id(relay_config, user_id);
|
||||
|
||||
let client = crate::channels::relay::RelayClient::new(
|
||||
effective_url.clone(),
|
||||
relay_config.url.clone(),
|
||||
relay_config.api_key.clone(),
|
||||
relay_config.request_timeout_secs,
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to create relay HTTP client"
|
||||
);
|
||||
ExtensionError::ActivationFailed(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||
|
||||
// Fetch the per-instance signing secret from channel-relay.
|
||||
// This must succeed — there is no fallback.
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"activate_channel_relay: fetching signing secret from channel-relay"
|
||||
);
|
||||
let signing_secret = client.get_signing_secret(&team_id).await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to fetch signing secret from channel-relay"
|
||||
);
|
||||
ExtensionError::Config(format!("Failed to fetch relay signing secret: {e}"))
|
||||
})?;
|
||||
|
||||
@@ -4536,29 +4289,16 @@ impl ExtensionManager {
|
||||
// Hot-add to channel manager
|
||||
let cm_guard = self.relay_channel_manager.read().await;
|
||||
let channel_mgr = cm_guard.as_ref().ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: channel manager not initialized"
|
||||
);
|
||||
ExtensionError::ActivationFailed("Channel manager not initialized".to_string())
|
||||
})?;
|
||||
|
||||
channel_mgr.hot_add(Box::new(channel)).await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: hot_add to channel manager failed"
|
||||
);
|
||||
ExtensionError::ActivationFailed(e.to_string())
|
||||
})?;
|
||||
channel_mgr
|
||||
.hot_add(Box::new(channel))
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||
|
||||
if let Ok(mut cache) = self.relay_signing_secret_cache.lock() {
|
||||
*cache = Some(signing_secret);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: failed to cache signing secret (mutex poisoned)"
|
||||
);
|
||||
}
|
||||
|
||||
// Store the event sender so the web gateway's relay webhook endpoint can push events
|
||||
@@ -4576,12 +4316,6 @@ impl ExtensionManager {
|
||||
self.broadcast_extension_status(name, "active", Some(&status_msg))
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
instance_id = %instance_id,
|
||||
"activate_channel_relay: relay channel activated successfully"
|
||||
);
|
||||
|
||||
Ok(ActivateResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::ChannelRelay,
|
||||
@@ -4861,41 +4595,6 @@ impl ExtensionManager {
|
||||
}
|
||||
Ok(ExtensionSetupSchema { secrets, fields })
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {
|
||||
let relay_url_key = format!("extensions.{name}.relay_url");
|
||||
let current_url = if let Some(ref store) = self.store {
|
||||
match store.get_setting(&self.user_id, &relay_url_key).await {
|
||||
Ok(value_opt) => value_opt
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
setting_key = %relay_url_key,
|
||||
error = %e,
|
||||
"get_setup_schema: failed to read relay_url from settings"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let env_url = self.relay_config.as_ref().map(|c| c.url.as_str());
|
||||
Ok(ExtensionSetupSchema {
|
||||
secrets: Vec::new(),
|
||||
fields: vec![crate::channels::web::types::SetupFieldInfo {
|
||||
name: "relay_url".to_string(),
|
||||
prompt: format!(
|
||||
"Channel-relay service URL (leave empty to use env default{})",
|
||||
env_url.map(|u| format!(": {u}")).unwrap_or_default()
|
||||
),
|
||||
optional: true,
|
||||
provided: current_url.is_some(),
|
||||
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
|
||||
}],
|
||||
})
|
||||
}
|
||||
_ => Ok(ExtensionSetupSchema {
|
||||
secrets: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
@@ -5298,17 +4997,7 @@ impl ExtensionManager {
|
||||
names.insert(server.token_secret_name());
|
||||
(names, Vec::new())
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {
|
||||
let relay_fields = vec![crate::tools::wasm::ToolFieldSetupSchema {
|
||||
name: "relay_url".to_string(),
|
||||
prompt: "Channel-relay service URL override".to_string(),
|
||||
optional: true,
|
||||
setting_path: Some(format!("extensions.{name}.relay_url")),
|
||||
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
|
||||
restart_required: false,
|
||||
}];
|
||||
(std::collections::HashSet::new(), relay_fields)
|
||||
}
|
||||
ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let allowed_fields: std::collections::HashSet<String> =
|
||||
@@ -5399,28 +5088,13 @@ impl ExtensionManager {
|
||||
)));
|
||||
}
|
||||
let trimmed = field_value.trim();
|
||||
let field_def = setup_field_defs.get(field_name);
|
||||
|
||||
// Empty value on an optional field with a setting_path: clear the
|
||||
// stored override so the system reverts to the env/default value.
|
||||
if trimmed.is_empty() {
|
||||
if let Some(def) = field_def
|
||||
&& def.optional
|
||||
{
|
||||
stored_fields.remove(field_name);
|
||||
if let Some(setting_path) = &def.setting_path {
|
||||
Self::validate_setup_setting_path(name, setting_path)?;
|
||||
if let Some(store) = self.store.as_ref() {
|
||||
let _ = store.delete_setting(&self.user_id, setting_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_fields.insert(field_name.clone(), trimmed.to_string());
|
||||
|
||||
if let Some(field_def) = field_def {
|
||||
if let Some(field_def) = setup_field_defs.get(field_name) {
|
||||
if field_def.restart_required {
|
||||
restart_required = true;
|
||||
}
|
||||
@@ -7384,39 +7058,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: installed-but-not-authenticated relay must NOT short-circuit
|
||||
/// `auth_channel_relay()` to "authenticated". Previously, `auth_channel_relay`
|
||||
/// called `is_relay_channel()` which checked the in-memory
|
||||
/// `installed_relay_extensions` set; that returned `true` even when no team_id
|
||||
/// existed in the store, so the OAuth URL was never offered.
|
||||
#[tokio::test]
|
||||
async fn test_auth_channel_relay_installed_without_team_id_is_not_authenticated() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let mgr = make_test_manager(None, dir.path().to_path_buf());
|
||||
|
||||
// Mark as installed (simulates clicking Install in the UI)
|
||||
mgr.installed_relay_extensions
|
||||
.write()
|
||||
.await
|
||||
.insert("slack-relay".to_string());
|
||||
|
||||
// Without a stored team_id, auth should NOT return authenticated.
|
||||
// It should fail because relay config is missing (no CHANNEL_RELAY_URL),
|
||||
// but the key assertion is that it does NOT return Ok(authenticated).
|
||||
let result = mgr.auth_channel_relay("slack-relay", "test").await;
|
||||
match result {
|
||||
Ok(ref auth_result) if auth_result.is_authenticated() => {
|
||||
panic!(
|
||||
"auth_channel_relay returned authenticated for installed-but-no-team-id relay; \
|
||||
expected either an OAuth URL or a config error"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Config error (no relay URL) or awaiting_authorization — both are correct
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_relay_shuts_down_via_relay_channel_manager() {
|
||||
// Regression: remove() only checked channel_runtime for shutdown, missing
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
|
||||
@@ -451,7 +451,7 @@ impl NearAiChatProvider {
|
||||
provider: "nearai_chat".to_string(),
|
||||
reason: format!(
|
||||
"No model names found in response: {}",
|
||||
&response_text[..crate::util::floor_char_boundary(&response_text, 300)]
|
||||
&response_text[..response_text.len().min(300)]
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
+64
-6
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
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(¶ms, "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(¶ms, "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,
|
||||
});
|
||||
|
||||
|
||||
+1
-19
@@ -117,11 +117,6 @@ 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> {
|
||||
if !matches!(
|
||||
config.effective_transport(),
|
||||
@@ -219,14 +214,7 @@ 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
|
||||
@@ -247,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)
|
||||
|
||||
+16
-101
@@ -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.
|
||||
@@ -79,37 +78,33 @@ pub async fn create_client_from_config(
|
||||
Err(McpFactoryError::UnixNotSupported { name: server_name })
|
||||
}
|
||||
EffectiveTransport::Http => {
|
||||
// Authenticated (OAuth) path: tokens exist or server requires auth.
|
||||
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),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,84 +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,
|
||||
&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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user