Merge branch 'staging' into feat/wechat-integration

# Conflicts:
#	Cargo.lock
This commit is contained in:
Coffee
2026-03-27 14:46:13 +08:00
32 changed files with 1684 additions and 279 deletions
+61 -6
View File
@@ -122,18 +122,32 @@ 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(format!("{}/oauth/slack/auth", self.base_url))
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.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"
);
let status = resp.status();
if status.is_redirection() {
@@ -224,20 +238,39 @@ 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(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.post(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::proxy_provider: network request failed"
);
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,
@@ -255,23 +288,45 @@ 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(format!("{}/relay/signing-secret", self.base_url))
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::get_signing_secret: network request failed"
);
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()
+484 -5
View File
@@ -844,10 +844,10 @@ async fn oauth_callback_handler(
let result: Result<(), String> = async {
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
let oauth_proxy_auth_token = flow.oauth_proxy_auth_token().unwrap_or_default();
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token,
gateway_token: oauth_proxy_auth_token,
token_url: &flow.token_url,
client_id: &flow.client_id,
client_secret: flow.client_secret.as_deref(),
@@ -1185,11 +1185,31 @@ async fn slack_relay_oauth_callback_handler(
// Store team_id in settings
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
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
.set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id))
.await;
.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}")
})?;
// 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
@@ -2190,6 +2210,11 @@ 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(),
@@ -2197,6 +2222,10 @@ 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).
@@ -2215,6 +2244,13 @@ 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())));
}
@@ -2222,10 +2258,21 @@ 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) => Ok(Json(ActionResponse::fail(e.to_string()))),
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"extensions_activate_handler: retry after auth still failed"
);
Ok(Json(ActionResponse::fail(e.to_string())))
}
}
}
Ok(auth_result) => {
@@ -3135,6 +3182,160 @@ 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_returns_interactive_login_for_wechat() {
use axum::body::Body;
@@ -4083,6 +4284,284 @@ 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 {
+184 -5
View File
@@ -473,7 +473,8 @@ 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>>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
@@ -496,6 +497,12 @@ 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>>>;
@@ -529,6 +536,22 @@ 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);
@@ -674,6 +697,8 @@ 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,
@@ -687,6 +712,8 @@ 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,
@@ -729,7 +756,7 @@ fn oauth_token_response_from_json(
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
@@ -741,7 +768,7 @@ pub async fn exchange_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
"OAuth proxy auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
@@ -796,7 +823,7 @@ pub async fn exchange_via_proxy(
/// Refresh an OAuth access token via the platform's token refresh proxy.
///
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// Authenticated via an OAuth proxy 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(
@@ -804,7 +831,7 @@ pub async fn refresh_token_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token refresh".to_string(),
"OAuth proxy auth token is required for proxy token refresh".to_string(),
));
}
@@ -1010,6 +1037,37 @@ 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");
@@ -1030,6 +1088,79 @@ 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;
@@ -1535,6 +1666,54 @@ 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,6 +192,9 @@ 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".
+396 -36
View File
@@ -425,9 +425,10 @@ pub struct ExtensionManager {
/// when running in gateway mode, consumed by the web gateway's
/// `/oauth/callback` handler.
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
/// Gateway auth token for authenticating with the platform token exchange proxy.
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
gateway_token: Option<String>,
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
/// Resolved once at construction from `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`,
/// then `GATEWAY_AUTH_TOKEN` as a backward-compatible fallback.
oauth_proxy_auth_token: Option<String>,
/// Relay config captured at startup. Used by `auth_channel_relay` and
/// `activate_channel_relay` instead of re-reading env vars.
relay_config: Option<crate::config::RelayConfig>,
@@ -562,7 +563,7 @@ impl ExtensionManager {
activation_errors: RwLock::new(HashMap::new()),
sse_manager: RwLock::new(None),
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
oauth_proxy_auth_token: crate::cli::oauth_defaults::oauth_proxy_auth_token(),
relay_config: crate::config::RelayConfig::from_env(),
relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)),
relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)),
@@ -701,6 +702,66 @@ 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,
@@ -944,6 +1005,46 @@ 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
@@ -1478,7 +1579,7 @@ impl ExtensionManager {
let errors = self.activation_errors.read().await;
for name in installed.iter() {
let active = active_names.contains(name);
let authenticated = self.is_relay_channel(name, user_id).await;
let authenticated = self.has_stored_team_id(name, user_id).await;
let activation_error = errors.get(name).cloned();
let registry_entry = self
.registry
@@ -2750,7 +2851,7 @@ impl ExtensionManager {
user_id: user_id.to_string(),
secrets: Arc::clone(&self.secrets),
sse_manager: self.sse_manager.read().await.clone(),
gateway_token: self.gateway_token.clone(),
gateway_token: self.oauth_proxy_auth_token.clone(),
token_exchange_extra_params,
client_id_secret_name: if server.oauth.is_none() {
Some(server.client_id_secret_name())
@@ -3267,7 +3368,7 @@ impl ExtensionManager {
user_id: user_id.to_string(),
secrets: Arc::clone(&self.secrets),
sse_manager: self.sse_manager.read().await.clone(),
gateway_token: self.gateway_token.clone(),
gateway_token: self.oauth_proxy_auth_token.clone(),
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at: std::time::Instant::now(),
@@ -4266,20 +4367,69 @@ impl ExtensionManager {
name: &str,
user_id: &str,
) -> Result<AuthResult, ExtensionError> {
// Check if already authenticated (team_id setting exists)
if self.is_relay_channel(name, user_id).await {
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)"
);
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()?;
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 client = crate::channels::relay::RelayClient::new(
relay_config.url.clone(),
effective_url.clone(),
relay_config.api_key.clone(),
relay_config.request_timeout_secs,
)
.map_err(|e| ExtensionError::Config(e.to_string()))?;
.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())
})?;
// Generate CSRF nonce — IronClaw validates this on the callback to ensure
// the OAuth completion is legitimate. Channel-relay embeds it in the signed
@@ -4291,18 +4441,44 @@ impl ExtensionManager {
self.secrets
.create(user_id, CreateSecretParams::new(&state_key, &state_nonce))
.await
.map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?;
.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}"))
})?;
// 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) => Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::ChannelRelay,
auth_url,
"redirect".to_string(),
)),
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),
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()))
}
}
}
@@ -4312,40 +4488,112 @@ 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 {
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()
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()
}
}
} 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()?;
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 instance_id = self.relay_instance_id(relay_config, user_id);
let client = crate::channels::relay::RelayClient::new(
relay_config.url.clone(),
effective_url.clone(),
relay_config.api_key.clone(),
relay_config.request_timeout_secs,
)
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
.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())
})?;
// 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}"))
})?;
@@ -4364,16 +4612,29 @@ 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| ExtensionError::ActivationFailed(e.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())
})?;
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
@@ -4391,6 +4652,12 @@ 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,
@@ -4703,6 +4970,41 @@ impl ExtensionManager {
interactive_login: None,
})
}
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(),
@@ -5303,7 +5605,17 @@ impl ExtensionManager {
names.insert(server.token_secret_name());
(names, Vec::new())
}
ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), 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)
}
};
let allowed_fields: std::collections::HashSet<String> =
@@ -5394,13 +5706,28 @@ 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) = setup_field_defs.get(field_name) {
if let Some(field_def) = field_def {
if field_def.restart_required {
restart_required = true;
}
@@ -7566,6 +7893,39 @@ 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
+35 -3
View File
@@ -650,6 +650,23 @@ 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)
}
@@ -1093,6 +1110,7 @@ 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);
@@ -1274,6 +1292,7 @@ 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
@@ -1411,11 +1430,24 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
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 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)))?;
@@ -1430,7 +1462,7 @@ impl Tool for RoutineDeleteTool {
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": name,
"name": &name,
"deleted": deleted,
});
+19 -1
View File
@@ -117,6 +117,11 @@ 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(),
@@ -214,7 +219,14 @@ impl McpClient {
}
}
/// Attach a session manager for Streamable HTTP session tracking.
/// 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)]
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
@@ -235,6 +247,12 @@ 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)
+101 -16
View File
@@ -7,6 +7,7 @@ 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.
@@ -78,33 +79,37 @@ 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() {
Ok(McpClient::new_authenticated(
return 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),
))
}
}
}
@@ -134,4 +139,84 @@ 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"
);
}
}
+28
View File
@@ -494,6 +494,34 @@ 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;
+51 -4
View File
@@ -446,16 +446,14 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
builtin.as_ref(),
exchange_proxy_url.is_some(),
);
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
Some(OAuthRefreshConfig {
token_url: oauth.token_url.clone(),
client_id,
client_secret,
exchange_proxy_url,
gateway_token,
gateway_token: oauth_proxy_auth_token,
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
})
@@ -891,6 +889,11 @@ 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(),
@@ -982,6 +985,7 @@ 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 {
@@ -1021,6 +1025,7 @@ 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"));
@@ -1061,6 +1066,7 @@ 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 =
@@ -1095,6 +1101,47 @@ 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
// ---------------------------------------------------------------
+13 -5
View File
@@ -62,7 +62,8 @@ 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>,
/// Gateway auth token for authenticating with the hosted OAuth proxy.
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
/// Kept as `gateway_token` for public API compatibility.
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`.
@@ -71,6 +72,12 @@ 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.
@@ -1218,9 +1225,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(gateway_token) = config.gateway_token.as_deref() else {
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
tracing::warn!(
"OAuth refresh proxy is configured, but no gateway auth token is available"
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
);
return false;
};
@@ -1235,7 +1242,7 @@ async fn refresh_oauth_token(
let token_response = match oauth_defaults::refresh_token_via_proxy(
oauth_defaults::ProxyRefreshTokenRequest {
proxy_url,
gateway_token,
gateway_token: oauth_proxy_auth_token,
token_url: &config.token_url,
client_id: &config.client_id,
client_secret: config.client_secret.as_deref(),
@@ -2704,7 +2711,8 @@ mod tests {
}
#[tokio::test]
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
{
use crate::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
};