Merge pull request #1420 from nearai/staging-promote/71f9012d-23307625134

chore: promote staging to staging-promote/ec04354c-23271447493 (2026-03-19 17:20 UTC)
This commit is contained in:
Henry Park
2026-03-20 10:51:43 -07:00
committed by GitHub
46 changed files with 3297 additions and 1495 deletions
+1 -1
View File
@@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | | | Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth | | Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs | | DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | | | Per-group tool policies | ✅ | ❌ | |
+87 -13
View File
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
data: Option<T>, data: Option<T>,
} }
/// Tenant access token response. /// Tenant access token response (flat format).
#[derive(Debug, Default, Deserialize)] ///
struct TenantAccessTokenData { /// Unlike most Feishu APIs that nest results under `data`, the
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
/// `tenant_access_token`, and `expire` at the top level.
#[derive(Debug, Deserialize)]
struct TenantAccessTokenResponse {
#[serde(default)]
code: i32,
#[serde(default)]
msg: String,
tenant_access_token: String, tenant_access_token: String,
expire: i64, expire: i64,
} }
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let token_resp: FeishuApiResponse<TenantAccessTokenData> = let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
serde_json::from_slice(&response.body) .map_err(|e| format!("Failed to parse token response: {}", e))?;
.map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 { if token_resp.code != 0 {
return Err(format!( return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let data = token_resp if token_resp.tenant_access_token.is_empty() {
.data return Err("Token response missing tenant_access_token".to_string());
.ok_or_else(|| "Token response missing data".to_string())?; }
if token_resp.expire <= 0 {
return Err(format!(
"Token response has invalid expire value: {}",
token_resp.expire
));
}
// Cache the token with expiry. // Cache the token with expiry.
let now = channel_host::now_millis(); let now = channel_host::now_millis();
let expiry = now + (data.expire as u64) * 1000; let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token); let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string()); let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log( channel_host::log(
channel_host::LogLevel::Debug, channel_host::LogLevel::Debug,
&format!("Tenant access token refreshed, expires in {}s", data.expire), &format!(
"Tenant access token refreshed, expires in {}s",
token_resp.expire
),
); );
Ok(data.tenant_access_token) Ok(token_resp.tenant_access_token)
} }
Err(e) => Err(format!("Token exchange request failed: {}", e)), Err(e) => Err(format!("Token exchange request failed: {}", e)),
} }
@@ -819,3 +836,60 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
body: body_bytes, body: body_bytes,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_flat_token_response() {
let json = r#"{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-abc123",
"expire": 7200
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "ok");
assert_eq!(resp.tenant_access_token, "t-abc123");
assert_eq!(resp.expire, 7200);
}
#[test]
fn parse_token_response_rejects_missing_token() {
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when tenant_access_token is missing");
}
#[test]
fn parse_token_response_rejects_missing_expire() {
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when expire is missing");
}
#[test]
fn parse_token_response_defaults_code_and_msg() {
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "");
assert_eq!(resp.tenant_access_token, "t-abc");
assert_eq!(resp.expire, 3600);
}
#[test]
fn parse_token_error_response() {
let json = r#"{
"code": 10003,
"msg": "invalid app_id",
"tenant_access_token": "",
"expire": 0
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 10003);
assert!(resp.tenant_access_token.is_empty());
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "feishu", "name": "feishu",
"display_name": "Feishu / Lark Channel", "display_name": "Feishu / Lark Channel",
"kind": "channel", "kind": "channel",
"version": "0.1.1", "version": "0.1.2",
"wit_version": "0.3.0", "wit_version": "0.3.0",
"description": "Talk to your agent through a Feishu or Lark bot", "description": "Talk to your agent through a Feishu or Lark bot",
"keywords": [ "keywords": [
+39 -7
View File
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing. /// A tool requires approval before continuing.
NeedApproval { NeedApproval {
/// The pending approval request to store. /// The pending approval request to store.
pending: PendingApproval, pending: Box<PendingApproval>,
}, },
} }
@@ -217,9 +217,7 @@ impl Agent {
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"), reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
} }
.into()), .into()),
LoopOutcome::NeedApproval(pending) => { LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
}
} }
} }
@@ -482,6 +480,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize, usize,
crate::llm::ToolCall, crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>, Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None; )> = None;
for (idx, original_tc) in tool_calls.iter().enumerate() { for (idx, original_tc) in tool_calls.iter().enumerate() {
@@ -551,7 +550,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt && let Some(tool) = tool_opt
{ {
use crate::tools::ApprovalRequirement; use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) { let requirement = tool.requires_approval(&tc.arguments);
let needs_approval = match requirement {
ApprovalRequirement::Never => false, ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => { ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await; let sess = self.session.lock().await;
@@ -586,7 +586,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue; continue;
} }
approval_needed = Some((idx, tc, tool)); let allow_always = !matches!(requirement, ApprovalRequirement::Always);
approval_needed = Some((idx, tc, tool, allow_always));
break; break;
} }
} }
@@ -887,7 +888,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
} }
// Handle approval if a tool needed it // Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed { if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let display_params = redact_params(&tc.arguments, tool.sensitive_params()); let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval { let pending = PendingApproval {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
@@ -899,6 +900,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(), context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()), user_timezone: Some(self.user_tz.name().to_string()),
allow_always,
}; };
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending)))); return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
@@ -1365,6 +1367,35 @@ mod tests {
assert!(always_needs, "Always must always require approval"); assert!(always_needs, "Always must always require approval");
} }
/// Regression test: `allow_always` must be `false` for `Always` and
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
/// for tools that truly cannot be auto-approved.
#[test]
fn test_allow_always_matches_approval_requirement() {
use crate::tools::ApprovalRequirement;
// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);
// UnlessAutoApproved → allow_always = true
let req = ApprovalRequirement::UnlessAutoApproved;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(
allow_always,
"UnlessAutoApproved should set allow_always = true"
);
// Always → allow_always = false
let req = ApprovalRequirement::Always;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(!allow_always, "Always should set allow_always = false");
// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
let req = ApprovalRequirement::Never;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(allow_always, "Never should set allow_always = true");
}
#[test] #[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() { fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added // PendingApproval from before the deferred_tool_calls field was added
@@ -1410,6 +1441,7 @@ mod tests {
}, },
], ],
user_timezone: None, user_timezone: None,
allow_always: true,
}; };
let json = serde_json::to_string(&pending).expect("serialize"); let json = serde_json::to_string(&pending).expect("serialize");
+1
View File
@@ -211,6 +211,7 @@ mod tests {
job_id: job_id.to_string(), job_id: job_id.to_string(),
status: "completed".to_string(), status: "completed".to_string(),
session_id: None, session_id: None,
fallback_deliverable: None,
}, },
)) ))
.unwrap(); .unwrap();
+11
View File
@@ -188,6 +188,15 @@ pub struct PendingApproval {
/// through the approval flow even if the approval message lacks timezone. /// through the approval flow even if the approval message lacks timezone.
#[serde(default)] #[serde(default)]
pub user_timezone: Option<String>, pub user_timezone: Option<String>,
/// Whether the "always" auto-approve option should be offered to the user.
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
/// destructive shell commands), meaning every invocation must be confirmed.
#[serde(default = "default_true")]
pub allow_always: bool,
}
fn default_true() -> bool {
true
} }
/// A conversation thread within a session. /// A conversation thread within a session.
@@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")], context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: false,
}; };
thread.await_approval(approval); thread.await_approval(approval);
@@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![], context_messages: vec![],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: true,
}; };
thread.await_approval(approval); thread.await_approval(approval);
+2
View File
@@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String, description: String,
/// Parameters being passed. /// Parameters being passed.
parameters: serde_json::Value, parameters: serde_json::Value,
/// Whether "always" auto-approve should be offered to the user.
allow_always: bool,
}, },
/// Successfully processed (for control commands). /// Successfully processed (for control commands).
+21 -8
View File
@@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone(); let tool_name = pending.tool_name.clone();
let description = pending.description.clone(); let description = pending.description.clone();
let parameters = pending.display_parameters.clone(); let parameters = pending.display_parameters.clone();
thread.await_approval(pending); let allow_always = pending.allow_always;
thread.await_approval(*pending);
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -525,6 +527,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}) })
} }
Err(e) => { Err(e) => {
@@ -1069,28 +1072,31 @@ impl Agent {
usize, usize,
crate::llm::ToolCall, crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>, Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None; )> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() { for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await { if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip // Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always). // all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools { let (needs_approval, allow_always) = if self.config.auto_approve_tools {
false (false, true)
} else { } else {
use crate::tools::ApprovalRequirement; use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) { let requirement = tool.requires_approval(&tc.arguments);
let needs = match requirement {
ApprovalRequirement::Never => false, ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => { ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await; let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name) !sess.is_tool_auto_approved(&tc.name)
} }
ApprovalRequirement::Always => true, ApprovalRequirement::Always => true,
} };
(needs, !matches!(requirement, ApprovalRequirement::Always))
}; };
if needs_approval { if needs_approval {
approval_needed = Some((idx, tc.clone(), tool)); approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred break; // remaining tools stay deferred
} }
} }
@@ -1298,7 +1304,7 @@ impl Agent {
} }
// Handle approval if a tool needed it // Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed { if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let new_pending = PendingApproval { let new_pending = PendingApproval {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
@@ -1310,6 +1316,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval // Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(), user_timezone: pending.user_timezone.clone(),
allow_always,
}; };
let request_id = new_pending.request_id; let request_id = new_pending.request_id;
@@ -1333,6 +1340,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -1343,6 +1351,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}); });
} }
@@ -1411,7 +1420,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone(); let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone(); let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.clone(); let parameters = new_pending.display_parameters.clone();
thread.await_approval(new_pending); let allow_always = new_pending.allow_always;
thread.await_approval(*new_pending);
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -1421,6 +1431,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -1430,6 +1441,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}) })
} }
Err(e) => { Err(e) => {
@@ -1949,6 +1961,7 @@ mod tests {
context_messages: vec![], context_messages: vec![],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: false,
}; };
thread.await_approval(pending); thread.await_approval(pending);
+5 -2
View File
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager}; use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry; use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime; use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingProvider, Workspace}; use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
/// Fully initialized application components, ready for channel wiring /// Fully initialized application components, ready for channel wiring
/// and agent construction. /// and agent construction.
@@ -313,10 +313,13 @@ impl AppBuilder {
// Register memory tools if database is available // Register memory tools if database is available
let workspace = if let Some(ref db) = self.db { let workspace = if let Some(ref db) = self.db {
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone()) let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
.with_search_config(&self.config.search); .with_search_config(&self.config.search);
if let Some(ref emb) = embeddings { if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone()); ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
} }
let ws = Arc::new(ws); let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws)); tools.register_memory_tools(Arc::clone(&ws));
+5
View File
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String, tool_name: String,
description: String, description: String,
parameters: serde_json::Value, parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
}, },
/// Extension needs user authentication (token or OAuth). /// Extension needs user authentication (token or OAuth).
AuthRequired { AuthRequired {
+5
View File
@@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> { pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned() self.channels.read().await.get(name).cloned()
} }
/// Remove a channel from the manager.
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.write().await.remove(name)
}
} }
impl Default for ChannelManager { impl Default for ChannelManager {
+193 -383
View File
@@ -1,16 +1,16 @@
//! Channel trait implementation for channel-relay SSE streams. //! Channel trait implementation for channel-relay webhook callbacks.
//! //!
//! `RelayChannel` connects to a channel-relay service via SSE, converts //! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
//! incoming events to `IncomingMessage`s, and sends responses via the //! (pushed through an mpsc channel by the webhook handler), converts them
//! relay's provider-specific proxy API (Slack). //! to `IncomingMessage`s, and sends responses via the relay's provider-specific
//! proxy API (Slack).
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc}; use tokio::sync::mpsc;
use crate::channels::relay::client::{RelayClient, RelayError}; use crate::channels::relay::client::{ChannelEvent, RelayClient};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError; use crate::error::ChannelError;
@@ -39,44 +39,34 @@ impl RelayProvider {
} }
} }
/// Channel implementation that connects to a channel-relay SSE stream. /// Channel implementation that receives events from channel-relay via webhook callbacks.
pub struct RelayChannel { pub struct RelayChannel {
client: RelayClient, client: RelayClient,
provider: RelayProvider, provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, /// Sender side of the event channel — shared with the webhook handler.
/// SSE stream long-poll timeout in seconds. event_tx: mpsc::Sender<ChannelEvent>,
stream_timeout_secs: u64, /// Receiver side — taken once by `start()`.
/// Initial exponential backoff in milliseconds. event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds.
backoff_max_ms: u64,
/// Handle to the reconnect task for clean shutdown.
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Handle to the SSE parser task for clean shutdown.
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
/// Maximum consecutive reconnect failures before giving up.
max_consecutive_failures: u64,
} }
impl RelayChannel { impl RelayChannel {
/// Create a new relay channel for Slack (default provider). /// Create a new relay channel for Slack (default provider).
pub fn new( pub fn new(
client: RelayClient, client: RelayClient,
stream_token: String,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self { ) -> Self {
Self::new_with_provider( Self::new_with_provider(
client, client,
RelayProvider::Slack, RelayProvider::Slack,
stream_token,
team_id, team_id,
instance_id, instance_id,
user_id, event_tx,
event_rx,
) )
} }
@@ -84,44 +74,24 @@ impl RelayChannel {
pub fn new_with_provider( pub fn new_with_provider(
client: RelayClient, client: RelayClient,
provider: RelayProvider, provider: RelayProvider,
stream_token: String,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self { ) -> Self {
Self { Self {
client, client,
provider, provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id, team_id,
instance_id, instance_id,
user_id, event_tx,
stream_timeout_secs: 86400, event_rx: tokio::sync::Mutex::new(Some(event_rx)),
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
} }
} }
/// Set backoff/timeout parameters from relay config values. /// Get a clone of the event sender for wiring into the webhook endpoint.
pub fn with_timeouts( pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
mut self, self.event_tx.clone()
stream_timeout_secs: u64,
backoff_initial_ms: u64,
backoff_max_ms: u64,
) -> Self {
self.stream_timeout_secs = stream_timeout_secs;
self.backoff_initial_ms = backoff_initial_ms;
self.backoff_max_ms = backoff_max_ms;
self
}
/// Set the maximum number of consecutive reconnect failures before giving up.
pub fn with_max_failures(mut self, max: u64) -> Self {
self.max_consecutive_failures = max;
self
} }
/// Build a provider-appropriate proxy body for sending a message. /// Build a provider-appropriate proxy body for sending a message.
@@ -151,15 +121,9 @@ impl RelayChannel {
team_id: &str, team_id: &str,
method: &str, method: &str,
body: serde_json::Value, body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> { ) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
self.client self.client
.proxy_provider( .proxy_provider(self.provider.as_str(), team_id, method, body)
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.await .await
} }
} }
@@ -172,204 +136,82 @@ impl Channel for RelayChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> { async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string(); let channel_name = self.name().to_string();
let token = self.stream_token.read().await.clone();
let (stream, initial_parser_handle) = self
.client
.connect_stream(&token, self.stream_timeout_secs)
.await
.map_err(|e| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: e.to_string(),
})?;
*self.parser_handle.write().await = Some(initial_parser_handle); // Take the receiver (can only start once)
let mut event_rx =
self.event_rx
.lock()
.await
.take()
.ok_or_else(|| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: "RelayChannel already started".to_string(),
})?;
let (tx, rx) = mpsc::channel(64); let (tx, rx) = mpsc::channel(64);
// Spawn the stream reader + reconnect task
let client = self.client.clone();
let stream_token = Arc::clone(&self.stream_token);
let instance_id = self.instance_id.clone();
let user_id = self.user_id.clone();
let team_id = self.team_id.clone();
let stream_timeout_secs = self.stream_timeout_secs;
let backoff_initial_ms = self.backoff_initial_ms;
let backoff_max_ms = self.backoff_max_ms;
let max_consecutive_failures = self.max_consecutive_failures;
let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string(); let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone(); let relay_name = channel_name.clone();
let handle = tokio::spawn(async move { // Spawn a task that reads events from the webhook handler and converts to IncomingMessage
use futures::StreamExt; tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
let mut current_stream = stream; // Validate required fields
let mut backoff_ms = backoff_initial_ms; if event.sender_id.is_empty()
let mut consecutive_failures: u64 = 0; || event.channel_id.is_empty()
|| event.provider_scope.is_empty()
loop { {
// Read events from the current stream tracing::debug!(
while let Some(event) = current_stream.next().await {
// Reset backoff and failure count on successful event
backoff_ms = backoff_initial_ms;
consecutive_failures = 0;
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
event_type = %event.event_type, event_type = %event.event_type,
sender = %event.sender_id, sender_id = %event.sender_id,
channel = %event.channel_id, channel_id = %event.channel_id,
provider = %provider_str, "Relay: skipping event with missing required fields"
"Relay: received message from {}", provider_str
); );
continue;
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
} }
// Stream ended, attempt reconnect with backoff // Skip non-message events
consecutive_failures += 1; if !event.is_message() {
if consecutive_failures >= max_consecutive_failures { tracing::debug!(
tracing::error!( event_type = %event.event_type,
channel = %relay_name, "Relay: skipping non-message event"
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
); );
break; continue;
} }
tracing::warn!( tracing::info!(
backoff_ms = backoff_ms, event_type = %event.event_type,
failures = consecutive_failures, sender = %event.sender_id,
"Relay SSE stream ended, reconnecting..." channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
); );
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
let token = stream_token.read().await.clone(); .with_user_name(event.display_name())
match client.connect_stream(&token, stream_timeout_secs).await { .with_metadata(serde_json::json!({
Ok((new_stream, new_parser)) => { "team_id": event.team_id(),
tracing::info!("Relay SSE stream reconnected"); "channel_id": event.channel_id,
consecutive_failures = 0; "sender_id": event.sender_id,
backoff_ms = backoff_initial_ms; "sender_name": event.display_name(),
current_stream = new_stream; "event_type": event.event_type,
// Abort old parser before replacing "thread_id": event.thread_id,
if let Some(old) = parser_handle.write().await.take() { "provider": event.provider,
old.abort(); }));
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
consecutive_failures = 0;
backoff_ms = backoff_initial_ms;
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to reconnect after token renewal"
);
}
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to renew relay stream token"
);
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
}
}
// Check if the team is still valid (skip when team_id is unknown, let msg = if let Some(ref thread_id) = event.thread_id {
// e.g. when no DB store was available at activation time) msg.with_thread(thread_id)
if !team_id.is_empty() { } else {
match client.list_connections(&instance_id).await { msg.with_thread(&event.channel_id)
Ok(conns) => { };
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected); if tx.send(msg).await.is_err() {
if !has_team { tracing::info!("Relay channel receiver dropped, stopping");
tracing::warn!( return;
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
} }
} }
});
*self.reconnect_handle.write().await = Some(handle); tracing::info!("Relay event channel closed");
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx); let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream)) Ok(Box::pin(stream))
@@ -423,6 +265,7 @@ impl Channel for RelayChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always: _,
} = status } = status
else { else {
return Ok(()); return Ok(());
@@ -450,28 +293,24 @@ impl Channel for RelayChannel {
name: self.name().to_string(), name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(), reason: "Missing channel_id for approval buttons".into(),
})?; })?;
let sender_id = metadata
.get("sender_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: self.name().to_string(),
reason: "Missing sender_id for approval buttons".into(),
})?;
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str()); let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata let team_id = metadata
.get("team_id") .get("team_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&self.team_id); .unwrap_or(&self.team_id);
// Button value payload (Slack limits button values to 2000 chars; // Register server-side approval record and get opaque token.
// safe with typical UUIDs but documented here as a constraint) // The button value contains ONLY the token — no routing fields.
let approval_token = self
.client
.create_approval(team_id, channel_id, thread_id, &request_id)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name().to_string(),
reason: format!("Failed to register approval: {e}"),
})?;
let value_payload = serde_json::json!({ let value_payload = serde_json::json!({
"instance_id": self.instance_id, "approval_token": approval_token,
"team_id": team_id,
"channel_id": channel_id,
"thread_ts": thread_id,
"request_id": request_id,
"sender_id": sender_id,
}); });
let value_str = value_payload.to_string(); let value_str = value_payload.to_string();
@@ -582,12 +421,8 @@ impl Channel for RelayChannel {
} }
async fn shutdown(&self) -> Result<(), ChannelError> { async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() { // Relay cleanup is driven by the extension manager dropping the shared
handle.abort(); // sender and removing the channel from the channel manager.
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
Ok(()) Ok(())
} }
} }
@@ -605,27 +440,20 @@ mod tests {
.expect("client") .expect("client")
} }
fn make_channel() -> RelayChannel {
let (tx, rx) = mpsc::channel(64);
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
}
#[test] #[test]
fn relay_channel_name() { fn relay_channel_name() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.name(), DEFAULT_RELAY_NAME); assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
} }
#[test] #[test]
fn conversation_context_extracts_metadata() { fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"sender_name": "bob", "sender_name": "bob",
@@ -640,8 +468,6 @@ mod tests {
#[test] #[test]
fn metadata_shape_includes_event_type_and_sender_name() { fn metadata_shape_includes_event_type_and_sender_name() {
// Regression: metadata JSON must include event_type and sender_name
// for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"team_id": "T123", "team_id": "T123",
"channel_id": "C456", "channel_id": "C456",
@@ -651,43 +477,19 @@ mod tests {
"thread_id": null, "thread_id": null,
"provider": "slack", "provider": "slack",
}); });
// event_type must be present for DM-vs-channel routing
assert_eq!( assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()), metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message") Some("direct_message")
); );
// sender_name must be present for conversation_context
assert_eq!( assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()), metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice") Some("alice")
); );
} }
#[test]
fn with_timeouts_sets_values() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_timeouts(43200, 2000, 120000);
assert_eq!(channel.stream_timeout_secs, 43200);
assert_eq!(channel.backoff_initial_ms, 2000);
assert_eq!(channel.backoff_max_ms, 120000);
}
#[test] #[test]
fn build_send_body_slack() { fn build_send_body_slack() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890")); let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage"); assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456"); assert_eq!(body["channel"], "C456");
@@ -695,72 +497,95 @@ mod tests {
assert_eq!(body["thread_ts"], "1234567.890"); assert_eq!(body["thread_ts"], "1234567.890");
} }
#[test] #[tokio::test]
fn parser_handle_is_shared_arc() { async fn start_processes_events() {
let channel = RelayChannel::new( let (tx, rx) = mpsc::channel(64);
test_client(), let channel =
"token".into(), RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
"T123".into(),
"inst1".into(), let mut stream = channel.start().await.unwrap();
"user1".into(),
); // Send an event
// parser_handle should be an Arc — cloning should give a second reference tx.send(ChannelEvent {
let handle_clone = Arc::clone(&channel.parser_handle); id: "1".into(),
// Both point to the same allocation event_type: "message".into(),
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone)); provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: Some("alice".into()),
content: Some("hello".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "hello");
assert_eq!(msg.user_id, "U789");
} }
#[test] #[tokio::test]
fn with_max_failures_sets_value() { async fn start_skips_non_message_events() {
let channel = RelayChannel::new( let (tx, rx) = mpsc::channel(64);
test_client(), let channel =
"token".into(), RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
assert_eq!(channel.max_consecutive_failures, 10); let mut stream = channel.start().await.unwrap();
}
#[test] // Send a non-message event (should be skipped)
fn default_max_failures_is_50() { tx.send(ChannelEvent {
let channel = RelayChannel::new( id: "1".into(),
test_client(), event_type: "reaction".into(),
"token".into(), provider: "slack".into(),
"T123".into(), provider_scope: "T123".into(),
"inst1".into(), channel_id: "C456".into(),
"user1".into(), sender_id: "U789".into(),
); sender_name: None,
assert_eq!(channel.max_consecutive_failures, 50); content: None,
} thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
#[test] // Send a real message
fn empty_team_id_accepted_at_construction() { tx.send(ChannelEvent {
// Regression: empty team_id (when no DB store is available) must not id: "2".into(),
// prevent channel construction or cause immediate shutdown. event_type: "message".into(),
let channel = RelayChannel::new( provider: "slack".into(),
test_client(), provider_scope: "T123".into(),
"token".into(), channel_id: "C456".into(),
String::new(), // empty team_id sender_id: "U789".into(),
"inst1".into(), sender_name: None,
"user1".into(), content: Some("real message".into()),
); thread_id: None,
assert_eq!(channel.team_id, ""); raw: serde_json::Value::Null,
// The reconnect loop now skips team validation when team_id is empty, timestamp: None,
// so the channel remains alive. })
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "real message");
} }
#[tokio::test] #[tokio::test]
async fn test_send_status_non_approval_is_noop() { async fn test_send_status_non_approval_is_noop() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({}); let metadata = serde_json::json!({});
let result = channel let result = channel
.send_status( .send_status(
@@ -775,13 +600,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_non_dm_skips() { async fn test_send_status_approval_non_dm_skips() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "message", "event_type": "message",
"channel_id": "C456", "channel_id": "C456",
@@ -794,6 +613,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -804,13 +624,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() { async fn test_send_status_approval_dm_missing_channel_id_errors() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "direct_message", "event_type": "direct_message",
"sender_id": "U789", "sender_id": "U789",
@@ -822,6 +636,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -835,14 +650,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_dm_missing_sender_id_errors() { async fn test_send_status_approval_dm_without_sender_id_is_ok() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "direct_message", "event_type": "direct_message",
"channel_id": "C456", "channel_id": "C456",
@@ -854,6 +663,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -861,8 +671,8 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
err.contains("sender_id"), !err.contains("sender_id"),
"expected sender_id error, got: {err}" "sender_id should not be required anymore, got: {err}"
); );
} }
} }
+90 -205
View File
@@ -1,15 +1,10 @@
//! HTTP client for the channel-relay service. //! HTTP client for the channel-relay service.
//! //!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation, //! Wraps reqwest for all channel-relay API calls: OAuth initiation,
//! SSE streaming, token renewal, and Slack API proxy. //! approvals, signing-secret fetch, and Slack API proxy.
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::Stream;
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types. /// Known relay event types.
pub mod event_types { pub mod event_types {
@@ -18,7 +13,7 @@ pub mod event_types {
pub const MENTION: &str = "mention"; pub const MENTION: &str = "mention";
} }
/// A parsed SSE event from the channel-relay stream. /// A parsed event from the channel-relay webhook callback.
/// ///
/// Field names match the channel-relay `ChannelEvent` struct exactly. /// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,21 +118,19 @@ impl RelayClient {
/// ///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and /// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it. /// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth( /// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
&self, /// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
instance_id: &str, /// for validating the callback — no URLs.
user_id: &str, pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
callback_url: &str, let mut query: Vec<(&str, &str)> = vec![];
) -> Result<String, RelayError> { if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self let resp = self
.http .http
.get(format!("{}/oauth/slack/auth", self.base_url)) .get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.query(&[ .query(&query)
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.send() .send()
.await .await
.map_err(|e| RelayError::Network(e.to_string()))?; .map_err(|e| RelayError::Network(e.to_string()))?;
@@ -173,104 +166,69 @@ impl RelayClient {
} }
} }
/// Connect to the SSE event stream. /// Register a pending approval and return the opaque approval token.
/// ///
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the /// Calls `POST /approvals` with the target team/channel/request identifiers.
/// background SSE parser task. The caller is responsible for reconnection /// The returned token is embedded in Slack button values instead of routing fields.
/// logic on stream end/error and for aborting the handle on shutdown. /// The relay derives the authorized approver from the connection's authed_user_id.
pub async fn connect_stream( pub async fn create_approval(
&self, &self,
stream_token: &str, team_id: &str,
stream_timeout_secs: u64, channel_id: &str,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> { thread_ts: Option<&str>,
let resp = self request_id: &str,
.http
.get(format!("{}/stream", self.base_url))
.query(&[("token", stream_token)])
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RelayError::TokenExpired);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
// Spawn a background task that reads the SSE stream and sends parsed events
let (tx, rx) = mpsc::channel(64);
let byte_stream = resp.bytes_stream();
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
Ok((ChannelEventStream { rx }, handle))
}
/// Renew an expired stream token.
///
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
pub async fn renew_token(
&self,
instance_id: &str,
user_id: &str,
) -> Result<String, RelayError> { ) -> Result<String, RelayError> {
let mut body = serde_json::json!({
"team_id": team_id,
"channel_id": channel_id,
"request_id": request_id,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::Value::String(ts.to_string());
}
let resp = self let resp = self
.http .http
.post(format!("{}/stream/renew", self.base_url)) .post(format!("{}/approvals", self.base_url))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.json(&serde_json::json!({ .json(&body)
"instance_id": instance_id,
"user_id": user_id,
}))
.send() .send()
.await .await
.map_err(|e| RelayError::Network(e.to_string()))?; .map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status(); if !resp.status().is_success() {
if !status.is_success() { let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api { return Err(RelayError::Api {
status: status.as_u16(), status,
message: body, message: body,
}); });
} }
let body: serde_json::Value = resp let result: serde_json::Value = resp
.json() .json()
.await .await
.map_err(|e| RelayError::Protocol(e.to_string()))?; .map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token")) result
.get("approval_token")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string())) .ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
} }
/// Proxy an API call through channel-relay for any provider.
///
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider( pub async fn proxy_provider(
&self, &self,
provider: &str, provider: &str,
team_id: &str, team_id: &str,
method: &str, method: &str,
body: serde_json::Value, body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> { ) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)]; let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let resp = self let resp = self
.http .http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method)) .post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.query(&query) .query(&query)
.json(&body) .json(&body)
.send() .send()
@@ -291,12 +249,58 @@ impl RelayClient {
.map_err(|e| RelayError::Protocol(e.to_string())) .map_err(|e| RelayError::Protocol(e.to_string()))
} }
/// Fetch the per-instance callback signing secret from channel-relay.
///
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
/// 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 resp = self
.http
.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| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status,
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("signing_secret")
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
.and_then(|raw| {
let decoded = hex::decode(raw).map_err(|e| {
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
})?;
if decoded.len() != 32 {
return Err(RelayError::Protocol(format!(
"invalid signing_secret length: expected 32 bytes, got {}",
decoded.len()
)));
}
Ok(decoded)
})
}
/// List active connections for an instance. /// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> { pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self let resp = self
.http .http
.get(format!("{}/connections", self.base_url)) .get(format!("{}/connections", self.base_url))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.query(&[("instance_id", instance_id)]) .query(&[("instance_id", instance_id)])
.send() .send()
.await .await
@@ -317,91 +321,6 @@ impl RelayClient {
} }
} }
/// Async stream of parsed channel events from SSE.
pub struct ChannelEventStream {
rx: mpsc::Receiver<ChannelEvent>,
}
impl Stream for ChannelEventStream {
type Item = ChannelEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Parse SSE format from a reqwest bytes stream.
///
/// SSE format:
/// ```text
/// event: message
/// data: {"key": "value"}
///
/// ```
/// Blank line terminates an event.
async fn parse_sse_stream(
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
tx: mpsc::Sender<ChannelEvent>,
) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}
tracing::debug!("SSE stream ended");
}
/// Errors from relay client operations. /// Errors from relay client operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RelayError { pub enum RelayError {
@@ -413,9 +332,6 @@ pub enum RelayError {
#[error("Protocol error: {0}")] #[error("Protocol error: {0}")]
Protocol(String), Protocol(String),
#[error("Stream token expired")]
TokenExpired,
} }
#[cfg(test)] #[cfg(test)]
@@ -494,9 +410,6 @@ mod tests {
message: "unauthorized".into(), message: "unauthorized".into(),
}; };
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized"); assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
} }
#[test] #[test]
@@ -518,32 +431,4 @@ mod tests {
assert!(make(event_types::DIRECT_MESSAGE).is_message()); assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message()); assert!(make(event_types::MENTION).is_message());
} }
#[tokio::test]
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
// Split it across two chunks to verify no U+FFFD corruption.
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
let full = format!("event: message\ndata: {}\n\n", event_json);
let bytes = full.as_bytes();
// Find the crab emoji and split mid-character
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.expect("crab emoji not found");
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
let stream = futures::stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
parse_sse_stream(stream, tx).await;
let event = rx.recv().await.expect("should receive event");
assert_eq!(event.text(), "hello 🦀 world");
}
} }
+4 -3
View File
@@ -1,12 +1,13 @@
//! Channel-relay integration for connecting to external messaging platforms //! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service. //! (Slack) via the channel-relay service.
//! //!
//! The relay service handles OAuth, credential storage, webhook ingestion, //! The relay service handles OAuth, credential storage, and webhook ingestion.
//! and SSE event streaming. IronClaw consumes the SSE stream and sends //! IronClaw receives events via webhook callbacks and sends messages via the
//! messages via the relay's proxy API. //! relay's proxy API.
pub mod channel; pub mod channel;
pub mod client; pub mod client;
pub mod webhook;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel}; pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient; pub use client::RelayClient;
+66
View File
@@ -0,0 +1,66 @@
//! Shared relay webhook signature verification helpers.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Verify a relay callback HMAC signature.
pub fn verify_relay_signature(
secret: &[u8],
timestamp: &str,
body: &[u8],
signature: &str,
) -> bool {
verify_signature(secret, timestamp, body, signature)
}
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
let mut mac = match HmacSha256::new_from_slice(secret) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[test]
fn verify_valid_signature() {
let secret = b"test-secret";
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(secret, ts, body);
assert!(verify_signature(secret, ts, body, &sig));
}
#[test]
fn verify_wrong_secret_fails() {
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(b"correct", ts, body);
assert!(!verify_signature(b"wrong", ts, body, &sig));
}
#[test]
fn verify_tampered_body_fails() {
let secret = b"secret";
let ts = "1234567890";
let sig = make_signature(secret, ts, b"original");
assert!(!verify_signature(secret, ts, b"tampered", &sig));
}
}
+8 -3
View File
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
} => { } => {
let term_width = crossterm::terminal::size() let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize) .map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
} }
eprintln!(" \u{2502}"); eprintln!(" \u{2502}");
eprintln!( if allow_always {
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)" eprintln!(
); " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
} else {
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
}
eprintln!(" {bot_border}"); eprintln!(" {bot_border}");
eprintln!(); eprintln!();
} }
+11 -3
View File
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name, tool_name,
description: _, description: _,
parameters, parameters,
allow_always,
} = &status } = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{ {
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default(); let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
let always_line = if *allow_always {
format!(
"\n• `always` or `a` - Approve and auto-approve future {} requests",
tool_name
)
} else {
String::new()
};
let message = format!( let message = format!(
"⚠️ *Approval Required*\n\n\ "⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\ *Request ID:* `{}`\n\
*Tool:* {}\n\ *Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\ *Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\ Reply with:\n\
• `yes` or `y` - Approve this request\n\ • `yes` or `y` - Approve this request{}\n\
• `always` or `a` - Approve and auto-approve future {} requests\n\
• `no` or `n` - Deny", • `no` or `n` - Deny",
request_id, tool_name, params_json, tool_name request_id, tool_name, params_json, always_line
); );
self.send_status_message(target_str, &message).await; self.send_status_message(target_str, &message).await;
} }
+26 -9
View File
@@ -2043,6 +2043,7 @@ impl WasmChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
.. ..
} => { } => {
// WASM channels (Telegram, Slack, etc.) cannot render // WASM channels (Telegram, Slack, etc.) cannot render
@@ -2081,6 +2082,11 @@ impl WasmChannel {
}) })
.unwrap_or_default(); .unwrap_or_default();
let reply_hint = if *allow_always {
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
} else {
"Reply \"yes\" to approve or \"no\" to deny."
};
let prompt = format!( let prompt = format!(
"Approval needed: {tool_name}\n\ "Approval needed: {tool_name}\n\
{description}\n\ {description}\n\
@@ -2088,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\ Parameters:\n\
{params_preview}\n\ {params_preview}\n\
\n\ \n\
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve." {reply_hint}"
); );
let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2981,15 +2987,23 @@ fn status_to_wit(
request_id, request_id,
tool_name, tool_name,
description, description,
allow_always,
.. ..
} => wit_channel::StatusUpdate { } => {
status: wit_channel::StatusType::ApprovalNeeded, let reply_hint = if *allow_always {
message: format!( "yes (or /approve), no (or /deny), or always (or /always)"
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).", } else {
tool_name, description, request_id "yes (or /approve) or no (or /deny)"
), };
metadata_json, wit_channel::StatusUpdate {
}, status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
tool_name, description, request_id, reply_hint
),
metadata_json,
}
}
StatusUpdate::JobStarted { StatusUpdate::JobStarted {
job_id, job_id,
title, title,
@@ -3670,6 +3684,7 @@ mod tests {
tool_name: "http_request".into(), tool_name: "http_request".into(),
description: "Fetch weather".into(), description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}), parameters: serde_json::json!({"url": "https://wttr.in"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -4131,6 +4146,7 @@ mod tests {
tool_name: "http_request".to_string(), tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(), description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -4156,6 +4172,7 @@ mod tests {
tool_name: "http_request".to_string(), tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(), description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
+2
View File
@@ -374,6 +374,7 @@ impl Channel for GatewayChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
} => SseEvent::ApprovalNeeded { } => SseEvent::ApprovalNeeded {
request_id, request_id,
tool_name, tool_name,
@@ -381,6 +382,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters) parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()), .unwrap_or_else(|_| parameters.to_string()),
thread_id, thread_id,
allow_always,
}, },
StatusUpdate::AuthRequired { StatusUpdate::AuthRequired {
extension_name, extension_name,
+236 -81
View File
@@ -19,6 +19,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer}; use tower_http::cors::{AllowHeaders, CorsLayer};
@@ -63,6 +64,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot = pub type RoutineEngineSlot =
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>; Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
fn redact_oauth_state_for_logs(state: &str) -> String {
let digest = Sha256::digest(state.as_bytes());
let mut short_hash = String::with_capacity(12);
for byte in &digest[..6] {
use std::fmt::Write as _;
let _ = write!(&mut short_hash, "{byte:02x}");
}
format!("sha256:{short_hash}:len={}", state.len())
}
/// Simple sliding-window rate limiter. /// Simple sliding-window rate limiter.
/// ///
/// Tracks the number of requests in the current window. Resets when the window expires. /// Tracks the number of requests in the current window. Resets when the window expires.
@@ -218,7 +229,8 @@ pub async fn start_server(
.route( .route(
"/oauth/slack/callback", "/oauth/slack/callback",
get(slack_relay_oauth_callback_handler), get(slack_relay_oauth_callback_handler),
); )
.route("/relay/events", post(relay_events_handler));
// Protected routes (require auth) // Protected routes (require auth)
let auth_state = AuthState { token: auth_token }; let auth_state = AuthState { token: auth_token };
@@ -565,22 +577,35 @@ async fn oauth_callback_handler(
} }
}; };
// Strip instance prefix from state for registry lookup. let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only. Ok(decoded) => decoded,
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param); Err(error) => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
tracing::warn!(
state = %redacted_state,
error = %error,
"OAuth callback received with malformed state"
);
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
let lookup_key = decoded_state.flow_id.clone();
let flow = ext_mgr let flow = ext_mgr
.pending_oauth_flows() .pending_oauth_flows()
.write() .write()
.await .await
.remove(lookup_key); .remove(&lookup_key);
let flow = match flow { let flow = match flow {
Some(f) => f, Some(f) => f,
None => { None => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!( tracing::warn!(
state = %state_param, state = %redacted_state,
lookup_key = %lookup_key, lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state" "OAuth callback received with unknown or expired state"
); );
clear_auth_mode(&state).await; clear_auth_mode(&state).await;
@@ -607,33 +632,29 @@ async fn oauth_callback_handler(
} }
// Exchange the authorization code for tokens. // Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container), // Use the platform exchange proxy when configured, otherwise call the
// otherwise call the provider's token URL directly. // provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok(); let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
let result: Result<(), String> = async { let result: Result<(), String> = async {
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource) let token_response = if let Some(proxy_url) = &exchange_proxy_url {
{
// Use the platform exchange proxy when configured and no resource
// parameter is needed. The proxy holds client_secret server-side so
// the container never sees it. MCP flows (resource.is_some()) bypass
// the proxy because it doesn't forward the RFC 8707 resource param.
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default(); let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy( oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url, proxy_url,
gateway_token, gateway_token,
&code, token_url: &flow.token_url,
&flow.redirect_uri, client_id: &flow.client_id,
flow.code_verifier.as_deref(), client_secret: flow.client_secret.as_deref(),
&flow.access_token_field, code: &code,
) redirect_uri: &flow.redirect_uri,
code_verifier: flow.code_verifier.as_deref(),
access_token_field: &flow.access_token_field,
extra_token_params: &flow.token_exchange_extra_params,
})
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} else { } else {
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP oauth_defaults::exchange_oauth_code_with_params(
// flows can include the RFC 8707 `resource` parameter to scope the
// issued token to the specific MCP server.
oauth_defaults::exchange_oauth_code_with_resource(
&flow.token_url, &flow.token_url,
&flow.client_id, &flow.client_id,
flow.client_secret.as_deref(), flow.client_secret.as_deref(),
@@ -641,7 +662,7 @@ async fn oauth_callback_handler(
&flow.redirect_uri, &flow.redirect_uri,
flow.code_verifier.as_deref(), flow.code_verifier.as_deref(),
&flow.access_token_field, &flow.access_token_field,
flow.resource.as_deref(), &flow.token_exchange_extra_params,
) )
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -668,10 +689,8 @@ async fn oauth_callback_handler(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// For MCP OAuth flows (identified by resource field), persist the // Persist the client_id for flows that need it after the session ends
// client_id so token refresh works without re-authentication. // (for example DCR-based MCP refresh).
// The CLI flow stores this in authorize_mcp_server(); the gateway
// callback must do the same.
if let Some(ref client_id_secret) = flow.client_id_secret_name { if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id) let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default()); .with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
@@ -752,11 +771,103 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response() axum::response::Html(html).into_response()
} }
/// Webhook endpoint for receiving relay events from channel-relay.
///
/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
async fn relay_events_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> impl IntoResponse {
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
}
};
let signing_secret = match ext_mgr.relay_signing_secret() {
Some(s) => s,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
}
};
// Verify signature
let signature = match headers
.get("x-relay-signature")
.and_then(|v| v.to_str().ok())
{
Some(s) => s.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
}
};
let timestamp = match headers
.get("x-relay-timestamp")
.and_then(|v| v.to_str().ok())
{
Some(t) => t.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
}
};
// Check timestamp freshness (5 min window)
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => {
return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
}
};
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > 300 {
return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
}
// Verify HMAC: sha256(secret, timestamp + "." + body)
if !crate::channels::relay::webhook::verify_relay_signature(
&signing_secret,
&timestamp,
&body,
&signature,
) {
return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
}
// Parse event
let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "relay callback invalid JSON");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
// Push to relay channel
let event_tx_guard = ext_mgr.relay_event_tx();
let event_tx = event_tx_guard.lock().await;
match event_tx.as_ref() {
Some(tx) => {
if let Err(e) = tx.try_send(event) {
tracing::warn!(error = %e, "relay event channel full or closed");
return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
}
}
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
}
}
Json(serde_json::json!({"ok": true})).into_response()
}
/// OAuth callback for Slack via channel-relay. /// OAuth callback for Slack via channel-relay.
/// ///
/// This is a PUBLIC route (no Bearer token required) because channel-relay /// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes. /// redirects the user's browser here after Slack OAuth completes.
/// Query params: `stream_token`, `provider`, `team_id`. /// Query params: `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler( async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
@@ -773,27 +884,6 @@ async fn slack_relay_oauth_callback_handler(
.into_response(); .into_response();
} }
// Validate stream_token: required, non-empty, max 2048 bytes
let stream_token = match params.get("stream_token") {
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
Some(t) if t.len() > 2048 => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars) // Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default(); let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() { if !team_id.is_empty() {
@@ -879,30 +969,16 @@ async fn slack_relay_oauth_callback_handler(
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await; let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async { let result: Result<(), String> = async {
// Store the stream token as a secret let store = state.store.as_ref().ok_or_else(|| {
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME); "Relay activation requires persistent settings storage; no-db mode is unsupported."
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await; .to_string()
ext_mgr })?;
.secrets()
.create(
&state.user_id,
crate::secrets::CreateSecretParams {
name: token_key,
value: secrecy::SecretString::from(stream_token),
provider: Some(provider.clone()),
expires_at: None,
},
)
.await
.map_err(|e| format!("Failed to store stream token: {}", e))?;
// Store team_id in settings // Store team_id in settings
if let Some(ref store) = state.store { let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME); let _ = store
let _ = store .set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id)) .await;
.await;
}
// Activate the relay channel // Activate the relay channel
ext_mgr ext_mgr
@@ -3253,7 +3329,7 @@ mod tests {
secrets, secrets,
sse_sender: None, sse_sender: None,
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
created_at, created_at,
}; };
@@ -3321,7 +3397,7 @@ mod tests {
secrets, secrets,
sse_sender: Some(sender), sse_sender: Some(sender),
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
created_at, created_at,
}; };
@@ -3424,7 +3500,7 @@ mod tests {
secrets, secrets,
sse_sender: None, sse_sender: None,
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O) // Expired — handler will reject after lookup (no network I/O)
created_at, created_at,
@@ -3476,6 +3552,85 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_oauth_callback_accepts_versioned_hosted_state() {
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 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_sender: 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", 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("Authorization Failed"));
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
// --- Slack relay OAuth CSRF tests --- // --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router { fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
@@ -3533,7 +3688,7 @@ mod tests {
// Callback without state param should be rejected // Callback without state param should be rejected
let req = axum::http::Request::builder() let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack") .uri("/oauth/slack/callback?team_id=T123&provider=slack")
.body(Body::empty()) .body(Body::empty())
.expect("request"); .expect("request");
@@ -3577,7 +3732,7 @@ mod tests {
// Callback with wrong state param // Callback with wrong state param
let req = axum::http::Request::builder() let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce") .uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty()) .body(Body::empty())
.expect("request"); .expect("request");
@@ -3625,7 +3780,7 @@ mod tests {
// we just verify it doesn't return a CSRF error. // we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder() let req = axum::http::Request::builder()
.uri(format!( .uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}", "/oauth/slack/callback?team_id=T123&provider=slack&state={}",
nonce nonce
)) ))
.body(Body::empty()) .body(Body::empty())
+7 -6
View File
@@ -1138,18 +1138,19 @@ function showApproval(data) {
approveBtn.textContent = I18n.t('approval.approve'); approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button'); const denyBtn = document.createElement('button');
denyBtn.className = 'deny'; denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny'); denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn); actions.appendChild(approveBtn);
actions.appendChild(alwaysBtn); if (data.allow_always !== false) {
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
actions.appendChild(alwaysBtn);
}
actions.appendChild(denyBtn); actions.appendChild(denyBtn);
card.appendChild(actions); card.appendChild(actions);
+5
View File
@@ -177,6 +177,8 @@ pub enum SseEvent {
parameters: String, parameters: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>, thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
}, },
#[serde(rename = "auth_required")] #[serde(rename = "auth_required")]
AuthRequired { AuthRequired {
@@ -230,6 +232,8 @@ pub enum SseEvent {
status: String, status: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>, session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
}, },
/// An image was generated by a tool. /// An image was generated by a tool.
@@ -1080,6 +1084,7 @@ mod tests {
description: "Run ls".to_string(), description: "Run ls".to_string(),
parameters: "{}".to_string(), parameters: "{}".to_string(),
thread_id: Some("t1".to_string()), thread_id: Some("t1".to_string()),
allow_always: true,
}; };
let ws = WsServerMessage::from_sse_event(&sse); let ws = WsServerMessage::from_sse_event(&sse);
match ws { match ws {
+59 -3
View File
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check( check(
"NEAR AI session", "NEAR AI session",
check_nearai_session().await, check_nearai_session(&settings).await,
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped, &mut skipped,
@@ -215,7 +215,22 @@ fn check_settings_file() -> CheckResult {
// ── NEAR AI session ───────────────────────────────────────── // ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult { async fn check_nearai_session(settings: &Settings) -> CheckResult {
// Skip entirely when the configured backend is not NEAR AI.
let llm_config = match crate::config::LlmConfig::resolve(settings) {
Ok(config) => config,
Err(e) => {
// check_llm_config will report the full error; just skip here.
return CheckResult::Skip(format!("LLM config error: {e}"));
}
};
if llm_config.backend != "nearai" {
return CheckResult::Skip(format!(
"not using NEAR AI backend (backend={})",
llm_config.backend
));
}
// Check if session file exists // Check if session file exists
let session_path = crate::config::llm::default_session_path(); let session_path = crate::config::llm::default_session_path();
if !session_path.exists() { if !session_path.exists() {
@@ -620,12 +635,53 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn check_nearai_session_does_not_panic() { async fn check_nearai_session_does_not_panic() {
let result = check_nearai_session().await; let settings = Settings::default();
let result = check_nearai_session(&settings).await;
match result { match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {} CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
} }
} }
#[test]
fn check_nearai_session_skips_for_non_nearai_backend() {
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX.
unsafe {
match &self.1 {
Some(val) => std::env::set_var(self.0, val),
None => std::env::remove_var(self.0),
}
}
}
}
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let prev = std::env::var("LLM_BACKEND").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("LLM_BACKEND", "anthropic");
}
let _env_guard = EnvGuard("LLM_BACKEND", prev);
let settings = Settings::default();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = rt.block_on(check_nearai_session(&settings));
match result {
CheckResult::Skip(msg) => {
assert!(
msg.contains("backend=anthropic"),
"expected backend name in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for non-nearai backend, got: {}",
format_result(&other)
),
}
}
#[test] #[test]
fn check_settings_file_handles_missing() { fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic // Settings::default_path() might or might not exist, but must not panic
+5 -3
View File
@@ -7,17 +7,18 @@ use std::sync::Arc;
use clap::Subcommand; use clap::Subcommand;
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace}; use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
/// Run a memory command using the Database trait (works with any backend). /// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db( pub async fn run_memory_command_with_db(
cmd: MemoryCommand, cmd: MemoryCommand,
db: std::sync::Arc<dyn crate::db::Database>, db: std::sync::Arc<dyn crate::db::Database>,
embeddings: Option<Arc<dyn EmbeddingProvider>>, embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db); let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings { if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb); workspace = workspace.with_embeddings_cached(emb, cache_config);
} }
match cmd { match cmd {
@@ -85,10 +86,11 @@ pub async fn run_memory_command(
cmd: MemoryCommand, cmd: MemoryCommand,
pool: deadpool_postgres::Pool, pool: deadpool_postgres::Pool,
embeddings: Option<Arc<dyn EmbeddingProvider>>, embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut workspace = Workspace::new("default", pool); let mut workspace = Workspace::new("default", pool);
if let Some(emb) = embeddings { if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb); workspace = workspace.with_embeddings_cached(emb, cache_config);
} }
match cmd { match cmd {
+4 -1
View File
@@ -336,7 +336,10 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
.await .await
.map_err(|e| anyhow::anyhow!("{}", e))?; .map_err(|e| anyhow::anyhow!("{}", e))?;
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await let cache_config = crate::workspace::EmbeddingCacheConfig {
max_entries: config.embeddings.cache_size,
};
run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
} }
#[cfg(test)] #[cfg(test)]
+264 -74
View File
@@ -5,17 +5,10 @@
//! //!
//! # Built-in Credentials //! # Built-in Credentials
//! //!
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials //! Some providers ship with built-in OAuth credentials so users don't need to
//! so users don't need to register their own OAuth app. Google explicitly //! register their own OAuth app just to get started. Today this module only
//! documents that client_secret for "Desktop App" / "Installed App" types //! includes built-in defaults for Google-family tools, and those defaults can
//! is NOT actually secret. //! be overridden by provider-specific environment variables when needed.
//!
//! Default credentials are hardcoded below. They can be overridden at:
//!
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
//! env vars before building to replace the hardcoded defaults.
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
//! env vars, which take priority over built-in defaults.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -23,6 +16,7 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -60,6 +54,14 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
} }
} }
/// Returns the compile-time override env var name, if this provider supports one.
pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
match secret_name {
"google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
_ => None,
}
}
// ── Shared callback server ────────────────────────────────────────────── // ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers` // Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
@@ -173,9 +175,8 @@ pub async fn exchange_oauth_code(
code_verifier: Option<&str>, code_verifier: Option<&str>,
access_token_field: &str, access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
// Delegates to exchange_oauth_code_with_resource with resource=None. let extra_token_params = HashMap::new();
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter. exchange_oauth_code_with_params(
exchange_oauth_code_with_resource(
token_url, token_url,
client_id, client_id,
client_secret, client_secret,
@@ -183,16 +184,14 @@ pub async fn exchange_oauth_code(
redirect_uri, redirect_uri,
code_verifier, code_verifier,
access_token_field, access_token_field,
None, &extra_token_params,
) )
.await .await
} }
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter. /// Exchange an OAuth authorization code for tokens with generic extra form parameters.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource( pub async fn exchange_oauth_code_with_params(
token_url: &str, token_url: &str,
client_id: &str, client_id: &str,
client_secret: Option<&str>, client_secret: Option<&str>,
@@ -200,7 +199,7 @@ pub async fn exchange_oauth_code_with_resource(
redirect_uri: &str, redirect_uri: &str,
code_verifier: Option<&str>, code_verifier: Option<&str>,
access_token_field: &str, access_token_field: &str,
resource: Option<&str>, extra_token_params: &HashMap<String, String>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut token_params = vec![ let mut token_params = vec![
@@ -213,10 +212,8 @@ pub async fn exchange_oauth_code_with_resource(
token_params.push(("code_verifier", verifier.to_string())); token_params.push(("code_verifier", verifier.to_string()));
} }
// RFC 8707: include the `resource` parameter so the authorization server for (key, value) in extra_token_params {
// scopes the issued token to the specific MCP server (protected resource). token_params.push((key.as_str(), value.clone()));
if let Some(resource) = resource {
token_params.push(("resource", resource.to_string()));
} }
let mut request = client.post(token_url); let mut request = client.post(token_url);
@@ -276,6 +273,37 @@ pub async fn exchange_oauth_code_with_resource(
}) })
} }
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
#[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
resource: Option<&str>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let mut extra_token_params = HashMap::new();
if let Some(resource) = resource {
extra_token_params.insert("resource".to_string(), resource.to_string());
}
exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
code,
redirect_uri,
code_verifier,
access_token_field,
&extra_token_params,
)
.await
}
/// Store OAuth tokens (access + refresh) in the secrets store. /// Store OAuth tokens (access + refresh) in the secrets store.
/// ///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope /// Also stores the granted scopes as `{secret_name}_scopes` so that scope
@@ -423,9 +451,9 @@ pub struct PendingOAuthFlow {
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>, pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy. /// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>, pub gateway_token: Option<String>,
/// RFC 8707 resource parameter (MCP OAuth only). /// Additional form params for the token exchange request.
/// Sent during token exchange to scope the token to a specific MCP server. /// Used for provider-specific requirements such as RFC 8707 `resource`.
pub resource: Option<String>, pub token_exchange_extra_params: HashMap<String, String>,
/// Secret name for persisting the client ID (MCP OAuth only). /// Secret name for persisting the client ID (MCP OAuth only).
/// Needed so token refresh can find the client_id after the session ends. /// Needed so token refresh can find the client_id after the session ends.
pub client_id_secret_name: Option<String>, pub client_id_secret_name: Option<String>,
@@ -459,9 +487,7 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
/// URL, meaning the user's browser will redirect to a hosted gateway rather than /// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost. /// localhost.
pub fn use_gateway_callback() -> bool { pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.map(|raw| { .map(|raw| {
url::Url::parse(&raw) url::Url::parse(&raw)
.ok() .ok()
@@ -472,6 +498,13 @@ pub fn use_gateway_callback() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Returns the configured OAuth token-exchange proxy URL, if any.
pub fn exchange_proxy_url() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
.map(|url| url.trim().to_string())
.filter(|url| !url.is_empty())
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout). /// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300); pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -486,23 +519,117 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
// ── Platform routing helpers ──────────────────────────────────────── // ── Platform routing helpers ────────────────────────────────────────
/// Prepend instance name to CSRF state for platform routing. const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedHostedOAuthState {
pub flow_id: String,
pub instance_name: Option<String>,
pub is_legacy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HostedOAuthStatePayload {
flow_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
instance_name: Option<String>,
issued_at: u64,
}
fn current_instance_name() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
.or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
.filter(|v| !v.is_empty())
}
fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
let digest = Sha256::digest(payload_bytes);
URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
}
/// Build a versioned hosted OAuth state envelope.
/// ///
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name /// The encoded value is opaque to providers and can be decoded by both
/// from the `state` query parameter (format: `instance:nonce`) to route the /// IronClaw and the external auth proxy for routing and callback lookup.
/// OAuth callback to the correct container. pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
/// let payload = HostedOAuthStatePayload {
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set flow_id: flow_id.to_string(),
/// (local/non-platform mode). instance_name: instance_name
pub fn build_platform_state(nonce: &str) -> String { .map(str::trim)
let instance = std::env::var("IRONCLAW_INSTANCE_NAME") .filter(|v| !v.is_empty())
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME")) .map(str::to_string),
.ok() issued_at: std::time::SystemTime::now()
.filter(|v| !v.is_empty()); .duration_since(std::time::UNIX_EPOCH)
match instance { .unwrap_or_default()
Some(name) => format!("{}:{}", name, nonce), .as_secs(),
None => nonce.to_string(), };
let payload_json = match serde_json::to_vec(&payload) {
Ok(payload_json) => payload_json,
Err(error) => {
tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
return payload.flow_id;
}
};
let payload = URL_SAFE_NO_PAD.encode(&payload_json);
let checksum = hosted_state_checksum(&payload_json);
format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
}
/// Decode hosted OAuth state in either the new versioned format or the
/// legacy `instance:nonce`/`nonce` forms.
pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState, String> {
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
{
let expected_checksum = hosted_state_checksum(&payload_json);
if checksum != expected_checksum {
return Err("Hosted OAuth state checksum mismatch".to_string());
}
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
&& !payload.flow_id.trim().is_empty()
{
return Ok(DecodedHostedOAuthState {
flow_id: payload.flow_id,
instance_name: payload.instance_name.filter(|v| !v.is_empty()),
is_legacy: false,
});
}
} }
if let Some((instance_name, flow_id)) = state.split_once(':') {
if flow_id.is_empty() {
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
}
return Ok(DecodedHostedOAuthState {
flow_id: flow_id.to_string(),
instance_name: if instance_name.is_empty() {
None
} else {
Some(instance_name.to_string())
},
is_legacy: true,
});
}
if state.is_empty() {
return Err("Hosted OAuth state is empty".to_string());
}
Ok(DecodedHostedOAuthState {
flow_id: state.to_string(),
instance_name: None,
is_legacy: true,
})
}
/// Build the hosted callback state used by the public OAuth callback endpoint.
///
/// New flows emit a versioned opaque envelope, while callback decoding accepts
/// both the envelope and the legacy `instance:nonce` contract.
pub fn build_platform_state(nonce: &str) -> String {
encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
} }
/// Strip the instance prefix from a state parameter to recover the lookup nonce. /// Strip the instance prefix from a state parameter to recover the lookup nonce.
@@ -517,43 +644,62 @@ pub fn strip_instance_prefix(state: &str) -> &str {
.unwrap_or(state) .unwrap_or(state)
} }
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
pub client_secret: Option<&'a str>,
pub code: &'a str,
pub redirect_uri: &'a str,
pub code_verifier: Option<&'a str>,
pub access_token_field: &'a str,
pub extra_token_params: &'a HashMap<String, String>,
}
/// Exchange an OAuth authorization code via the platform's token exchange proxy. /// Exchange an OAuth authorization code via the platform's token exchange proxy.
/// ///
/// The proxy holds `client_secret` server-side so the container never sees it. /// Authenticated via the gateway auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). /// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
/// ///
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and /// The proxy expects standard OAuth form params plus optional provider-specific
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`. /// token params and returns a standard token response such as
/// `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy( pub async fn exchange_via_proxy(
proxy_url: &str, request: ProxyTokenExchangeRequest<'_>,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() { if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io( return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(), "Gateway auth token is required for proxy token exchange".to_string(),
)); ));
} }
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/')); let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60)) .timeout(Duration::from_secs(60))
.build() .build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?; .map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![ let mut params = vec![
("code", code.to_string()), ("code", request.code.to_string()),
("redirect_uri", redirect_uri.to_string()), ("redirect_uri", request.redirect_uri.to_string()),
("token_url", request.token_url.to_string()),
("client_id", request.client_id.to_string()),
("access_token_field", request.access_token_field.to_string()),
]; ];
if let Some(verifier) = code_verifier { if let Some(verifier) = request.code_verifier {
params.push(("code_verifier", verifier.to_string())); params.push(("code_verifier", verifier.to_string()));
} }
if let Some(secret) = request.client_secret {
params.push(("client_secret", secret.to_string()));
}
for (key, value) in request.extra_token_params {
params.push((key.as_str(), value.clone()));
}
let response = client let response = client
.post(&exchange_url) .post(&exchange_url)
.bearer_auth(gateway_token) .bearer_auth(request.gateway_token)
.form(&params) .form(&params)
.send() .send()
.await .await
@@ -576,7 +722,7 @@ pub async fn exchange_via_proxy(
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?; .map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data let access_token = token_data
.get(access_token_field) .get(request.access_token_field)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| { .ok_or_else(|| {
let fields: Vec<&str> = token_data let fields: Vec<&str> = token_data
@@ -585,7 +731,7 @@ pub async fn exchange_via_proxy(
.unwrap_or_default(); .unwrap_or_default();
OAuthCallbackError::Io(format!( OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})", "No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields request.access_token_field, fields
)) ))
})? })?
.to_string(); .to_string();
@@ -605,14 +751,10 @@ pub async fn exchange_via_proxy(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Mutex;
use crate::cli::oauth_defaults::{ use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html, builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
}; };
use crate::config::helpers::ENV_MUTEX;
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test] #[test]
fn test_is_loopback_host() { fn test_is_loopback_host() {
@@ -935,7 +1077,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_with_instance() { fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::build_platform_state; use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -943,7 +1085,11 @@ mod tests {
unsafe { unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer"); std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
} }
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123"); let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(!decoded.is_legacy);
unsafe { unsafe {
if let Some(val) = original { if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -955,7 +1101,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_without_instance() { fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::build_platform_state; use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -965,7 +1111,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME"); std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME"); std::env::remove_var("OPENCLAW_INSTANCE_NAME");
} }
assert_eq!(build_platform_state("abc123"), "abc123"); let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(!decoded.is_legacy);
unsafe { unsafe {
if let Some(val) = original { if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -978,7 +1128,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_with_openclaw_instance() { fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::build_platform_state; use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -988,7 +1138,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME"); std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion"); std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
} }
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789"); let encoded = build_platform_state("xyz789");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "xyz789");
assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
assert!(!decoded.is_legacy);
unsafe { unsafe {
if let Some(val) = original_ic { if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -1017,6 +1171,42 @@ mod tests {
assert_eq!(strip_instance_prefix(""), ""); assert_eq!(strip_instance_prefix(""), "");
} }
#[test]
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(decoded.is_legacy);
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_falls_back_for_non_envelope_ic2_prefix() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded =
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
let tampered = format!("{encoded}broken");
let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
assert!(err.contains("checksum"), "unexpected error: {err}");
}
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter /// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
/// when passed through `extra_params`, which is how MCP OAuth gateway mode /// when passed through `extra_params`, which is how MCP OAuth gateway mode
/// scopes tokens to a specific MCP server. /// scopes tokens to a specific MCP server.
+14 -7
View File
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
// Check for OAuth configuration // Check for OAuth configuration
if let Some(ref oauth) = auth.oauth { if let Some(ref oauth) = auth.oauth {
// For providers with shared tokens (e.g., all Google tools share google_oauth_token), // For providers with shared tokens, combine scopes from all installed
// combine scopes from all installed tools so one auth covers everything. // tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await; let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() { if combined.scopes.len() > oauth.scopes.len() {
let extra = combined.scopes.len() - oauth.scopes.len(); let extra = combined.scopes.len() - oauth.scopes.len();
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
} }
/// Scan the tools directory for all capabilities files sharing the same secret_name /// Scan the tools directory for all capabilities files sharing the same secret_name
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes /// and combine their OAuth scopes so one authorization covers the full shared
/// for ALL installed Google tools, so one login covers everything. /// credential set.
async fn combine_provider_scopes( async fn combine_provider_scopes(
tools_dir: &Path, tools_dir: &Path,
secret_name: &str, secret_name: &str,
@@ -736,11 +736,18 @@ async fn auth_tool_oauth(
}) })
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string())) .or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!( let mut message = format!(
"OAuth client_id not configured.\n\ "OAuth client_id not configured.\n\
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.", Set {} env var",
oauth.client_id_env.as_deref().unwrap_or("the client_id") oauth.client_id_env.as_deref().unwrap_or("the client_id")
) );
if let Some(override_env) =
oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
{
message.push_str(&format!(", or build with {override_env}"));
}
message.push('.');
anyhow::anyhow!(message)
})?; })?;
// Get client_secret: capabilities file > runtime env var > built-in defaults // Get client_secret: capabilities file > runtime env var > built-in defaults
+42 -5
View File
@@ -8,6 +8,9 @@ use crate::llm::SessionManager;
use crate::settings::Settings; use crate::settings::Settings;
use crate::workspace::EmbeddingProvider; use crate::workspace::EmbeddingProvider;
/// Default maximum number of cached embeddings.
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
/// Embeddings provider configuration. /// Embeddings provider configuration.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EmbeddingsConfig { pub struct EmbeddingsConfig {
@@ -26,6 +29,12 @@ pub struct EmbeddingsConfig {
/// Custom base URL for OpenAI-compatible embedding providers. /// Custom base URL for OpenAI-compatible embedding providers.
/// When set, overrides the default `https://api.openai.com`. /// When set, overrides the default `https://api.openai.com`.
pub openai_base_url: Option<String>, pub openai_base_url: Option<String>,
/// Maximum entries in the embedding LRU cache (default 10,000).
///
/// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
/// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
/// due to HashMap buckets, per-entry Vec/timestamp overhead).
pub cache_size: usize,
} }
impl Default for EmbeddingsConfig { impl Default for EmbeddingsConfig {
@@ -40,6 +49,7 @@ impl Default for EmbeddingsConfig {
ollama_base_url: "http://localhost:11434".to_string(), ollama_base_url: "http://localhost:11434".to_string(),
dimension, dimension,
openai_base_url: None, openai_base_url: None,
cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
} }
} }
} }
@@ -80,6 +90,15 @@ impl EmbeddingsConfig {
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?; let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
if cache_size == 0 {
return Err(ConfigError::InvalidValue {
key: "EMBEDDING_CACHE_SIZE".to_string(),
message: "must be at least 1".to_string(),
});
}
Ok(Self { Ok(Self {
enabled, enabled,
provider, provider,
@@ -88,6 +107,7 @@ impl EmbeddingsConfig {
ollama_base_url, ollama_base_url,
dimension, dimension,
openai_base_url, openai_base_url,
cache_size,
}) })
} }
@@ -183,13 +203,13 @@ mod tests {
std::env::remove_var("EMBEDDING_MODEL"); std::env::remove_var("EMBEDDING_MODEL");
std::env::remove_var("OPENAI_API_KEY"); std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("EMBEDDING_BASE_URL"); std::env::remove_var("EMBEDDING_BASE_URL");
std::env::remove_var("EMBEDDING_CACHE_SIZE");
} }
} }
#[test] #[test]
fn embeddings_disabled_not_overridden_by_openai_key() { fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env(); clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access. // SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { unsafe {
@@ -240,7 +260,6 @@ mod tests {
#[test] #[test]
fn embeddings_env_override_takes_precedence() { fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env(); clear_embedding_env();
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
@@ -281,10 +300,8 @@ mod tests {
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!( assert_eq!(
config.openai_base_url.as_deref(), config.openai_base_url.as_deref(),
Some("https://custom.example.com"), Some("https://custom.example.com")
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
); );
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
std::env::remove_var("EMBEDDING_BASE_URL"); std::env::remove_var("EMBEDDING_BASE_URL");
@@ -303,4 +320,24 @@ mod tests {
"openai_base_url should be None when EMBEDDING_BASE_URL is not set" "openai_base_url should be None when EMBEDDING_BASE_URL is not set"
); );
} }
#[test]
fn cache_size_zero_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
}
let settings = Settings::default();
let result = EmbeddingsConfig::resolve(&settings);
assert!(result.is_err(), "cache_size=0 should be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("at least 1"), "should mention minimum: {err}");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
} }
+1 -1
View File
@@ -38,7 +38,7 @@ pub use self::channels::{
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig, ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
}; };
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig; pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
pub use self::heartbeat::HeartbeatConfig; pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig; pub use self::hygiene::HygieneConfig;
pub use self::llm::default_session_path; pub use self::llm::default_session_path;
+29 -35
View File
@@ -7,7 +7,7 @@ use secrecy::SecretString;
pub struct RelayConfig { pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`). /// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String, pub url: String,
/// API key for authenticated channel-relay endpoints. /// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
pub api_key: SecretString, pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL). /// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>, pub callback_url: Option<String>,
@@ -15,12 +15,8 @@ pub struct RelayConfig {
pub instance_id: Option<String>, pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30). /// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64, pub request_timeout_secs: u64,
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h). /// Path for the webhook callback endpoint (default: `/relay/events`).
pub stream_timeout_secs: u64, pub webhook_path: String,
/// Initial exponential backoff in milliseconds (default: 1000).
pub backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds (default: 60000).
pub backoff_max_ms: u64,
} }
impl std::fmt::Debug for RelayConfig { impl std::fmt::Debug for RelayConfig {
@@ -31,9 +27,7 @@ impl std::fmt::Debug for RelayConfig {
.field("callback_url", &self.callback_url) .field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id) .field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs) .field("request_timeout_secs", &self.request_timeout_secs)
.field("stream_timeout_secs", &self.stream_timeout_secs) .field("webhook_path", &self.webhook_path)
.field("backoff_initial_ms", &self.backoff_initial_ms)
.field("backoff_max_ms", &self.backoff_max_ms)
.finish() .finish()
} }
} }
@@ -41,8 +35,10 @@ impl std::fmt::Debug for RelayConfig {
impl RelayConfig { impl RelayConfig {
/// Load relay config from environment variables. /// Load relay config from environment variables.
/// ///
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY` /// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
/// is not set, making the relay integration opt-in. /// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
/// The signing secret is fetched from channel-relay at activation time via
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
pub fn from_env() -> Option<Self> { pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok()) Self::from_env_reader(|key| std::env::var(key).ok())
} }
@@ -55,9 +51,7 @@ impl RelayConfig {
callback_url: None, callback_url: None,
instance_id: None, instance_id: None,
request_timeout_secs: 30, request_timeout_secs: 30,
stream_timeout_secs: 86400, webhook_path: "/relay/events".into(),
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
} }
} }
@@ -73,15 +67,7 @@ impl RelayConfig {
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS") request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(30), .unwrap_or(30),
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS") webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
.and_then(|v| v.parse().ok())
.unwrap_or(86400),
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(60000),
}) })
} }
} }
@@ -97,7 +83,21 @@ mod tests {
} }
#[test] #[test]
fn from_env_reader_loads_defaults() { fn from_env_reader_requires_only_url_and_api_key() {
// Signing secret is fetched at activation time — only URL + API key needed.
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
});
assert!(
config.is_some(),
"relay config should load with just URL + API key"
);
}
#[test]
fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key { let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()), "CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()), "CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
@@ -107,9 +107,7 @@ mod tests {
assert_eq!(config.url, "http://localhost:3001"); assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30); assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.stream_timeout_secs, 86400); assert_eq!(config.webhook_path, "/relay/events");
assert_eq!(config.backoff_initial_ms, 1000);
assert_eq!(config.backoff_max_ms, 60000);
assert!(config.callback_url.is_none()); assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none()); assert!(config.instance_id.is_none());
} }
@@ -122,9 +120,7 @@ mod tests {
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()), "IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()), "IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()), "RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()), "RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
_ => None, _ => None,
}) })
.expect("config should be Some"); .expect("config should be Some");
@@ -135,9 +131,7 @@ mod tests {
); );
assert_eq!(config.instance_id.as_deref(), Some("my-instance")); assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60); assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.stream_timeout_secs, 43200); assert_eq!(config.webhook_path, "/custom/events");
assert_eq!(config.backoff_initial_ms, 2000);
assert_eq!(config.backoff_max_ms, 120000);
} }
#[test] #[test]
@@ -148,7 +142,7 @@ mod tests {
} }
#[test] #[test]
fn debug_redacts_api_key() { fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret"); let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config); let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]")); assert!(debug.contains("[REDACTED]"));
+319
View File
@@ -0,0 +1,319 @@
//! Structured fallback deliverables for failed or stuck jobs.
//!
//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
//! what was accomplished before the failure: partial results, action statistics,
//! cost, and timing. This gives users visibility into terminal jobs instead of
//! just an error string.
//!
//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
//! and surfaced through the `job_status` tool.
use serde::{Deserialize, Serialize};
use crate::context::memory::Memory;
use crate::context::state::JobContext;
/// Structured summary of a failed or stuck job.
///
/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
/// or is marked stuck. Surfaced through the `job_status` tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FallbackDeliverable {
/// True if at least one action succeeded before failure.
pub partial: bool,
/// Why the job failed.
pub failure_reason: String,
/// Last action taken before failure.
pub last_action: Option<LastAction>,
/// Aggregate action statistics.
pub action_stats: ActionStats,
/// Total tokens consumed.
pub tokens_used: u64,
/// Total cost incurred (decimal as string for JSON safety).
pub cost: String,
/// Wall-clock elapsed time in seconds.
pub elapsed_secs: f64,
/// Number of self-repair attempts.
pub repair_attempts: u32,
}
/// Summary of the last action taken before failure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastAction {
pub tool_name: String,
/// Truncated to 200 bytes (UTF-8 safe).
pub output_preview: String,
pub success: bool,
}
/// Aggregate action counts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStats {
pub total: u32,
pub successful: u32,
pub failed: u32,
}
impl FallbackDeliverable {
/// Build a fallback deliverable from a job context and its memory.
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
let successful = memory.successful_actions() as u32;
let failed = memory.failed_actions() as u32;
let total = memory.actions.len() as u32;
let last_action = memory.last_action().map(|a| {
// Use sanitized output to avoid leaking secrets through the fallback API surface.
// For failed actions (no sanitized output), fall back to the error message.
// Borrow the string slice directly when possible to avoid cloning
// potentially large outputs just for truncation.
let owned_fallback;
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
match v {
serde_json::Value::String(s) => s.as_str(),
other => {
owned_fallback = serde_json::to_string(other).unwrap_or_default();
&owned_fallback
}
}
} else if let Some(ref err) = a.error {
err.as_str()
} else {
""
};
let preview = truncate_str(preview_str, 200);
LastAction {
tool_name: a.tool_name.clone(),
output_preview: preview.to_string(),
success: a.success,
}
});
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
Self {
partial: successful > 0,
failure_reason: truncate_str(reason, 1000).to_string(),
last_action,
action_stats: ActionStats {
total,
successful,
failed,
},
tokens_used: ctx.total_tokens_used,
cost: ctx.actual_cost.to_string(),
elapsed_secs,
repair_attempts: ctx.repair_attempts,
}
}
}
/// Truncate a string to at most `max_len` bytes on a char boundary.
fn truncate_str(s: &str, max_len: usize) -> &str {
&s[..crate::util::floor_char_boundary(s, max_len)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::memory::Memory;
use crate::context::state::JobContext;
use chrono::{Duration, Utc};
use rust_decimal::Decimal;
use std::time::Duration as StdDuration;
#[test]
fn test_fallback_zero_actions() {
let ctx = JobContext::new("Test", "Empty job");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
assert!(!fb.partial); // safety: test
assert_eq!(fb.failure_reason, "timed out"); // safety: test
assert!(fb.last_action.is_none()); // safety: test
assert_eq!(fb.action_stats.total, 0); // safety: test
assert_eq!(fb.action_stats.successful, 0); // safety: test
assert_eq!(fb.action_stats.failed, 0); // safety: test
assert_eq!(fb.tokens_used, 0); // safety: test
assert_eq!(fb.cost, "0"); // safety: test
assert_eq!(fb.repair_attempts, 0); // safety: test
}
#[test]
fn test_fallback_mixed_actions() {
let mut ctx = JobContext::new("Test", "Mixed job");
ctx.total_tokens_used = 5000;
ctx.actual_cost = Decimal::new(42, 2); // 0.42
ctx.repair_attempts = 1;
let mut memory = Memory::new(ctx.job_id);
// 3 successes
for _ in 0..3 {
let action = memory
.create_action("tool_a", serde_json::json!({}))
.succeed(
Some("output".to_string()),
serde_json::json!({}),
StdDuration::from_secs(1),
);
memory.record_action(action);
}
// 2 failures
for _ in 0..2 {
let action = memory
.create_action("tool_b", serde_json::json!({}))
.fail("broke", StdDuration::from_secs(1));
memory.record_action(action);
}
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
assert!(fb.partial); // safety: test
assert_eq!(fb.action_stats.total, 5); // safety: test
assert_eq!(fb.action_stats.successful, 3); // safety: test
assert_eq!(fb.action_stats.failed, 2); // safety: test
assert_eq!(fb.tokens_used, 5000); // safety: test
assert_eq!(fb.cost, "0.42"); // safety: test
assert_eq!(fb.repair_attempts, 1); // safety: test
assert!(fb.last_action.is_some()); // safety: test
let la = fb.last_action.unwrap(); // safety: test
assert_eq!(la.tool_name, "tool_b"); // safety: test
assert!(!la.success); // safety: test
// Failed actions should surface the error message as the output preview
assert_eq!(la.output_preview, "broke"); // safety: test
}
#[test]
fn test_fallback_failed_action_shows_error() {
let ctx = JobContext::new("Test", "Error preview");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("broken_tool", serde_json::json!({}))
.fail("connection timed out after 30s", StdDuration::from_secs(30));
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
let la = fb.last_action.unwrap(); // safety: test
assert!(!la.success); // safety: test
assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
}
#[test]
fn test_fallback_last_action_truncation() {
let ctx = JobContext::new("Test", "Truncation");
let mut memory = Memory::new(ctx.job_id);
let long_output = "x".repeat(500);
let action = memory
.create_action("tool_c", serde_json::json!({}))
.succeed(
Some(long_output.clone()),
serde_json::Value::String(long_output),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
assert!(la.output_preview.len() <= 200); // safety: test
assert!(!la.output_preview.is_empty()); // safety: test
}
#[test]
fn test_fallback_uses_sanitized_output() {
let ctx = JobContext::new("Test", "Sanitized");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("tool_d", serde_json::json!({}))
.succeed(
Some("[REDACTED]".to_string()),
serde_json::json!({"api_key": "sk-secret-key-12345"}),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
// Must use sanitized output, not raw
assert!(!la.output_preview.contains("sk-secret")); // safety: test
assert!(la.output_preview.contains("REDACTED")); // safety: test
}
#[test]
fn test_fallback_elapsed_time() {
let mut ctx = JobContext::new("Test", "Timing");
let now = Utc::now();
ctx.started_at = Some(now - Duration::seconds(10));
ctx.completed_at = Some(now);
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
// Should be approximately 10 seconds
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
}
#[test]
fn test_fallback_no_started_at() {
let ctx = JobContext::new("Test", "Never started");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
}
#[test]
fn test_fallback_elapsed_time_no_completed_at() {
let mut ctx = JobContext::new("Test", "Still running");
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
// completed_at is None — should use Utc::now() as fallback
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
// Should be approximately 5 seconds (using now as end time)
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
}
#[test]
fn test_fallback_failure_reason_truncation() {
let ctx = JobContext::new("Test", "Long reason");
let memory = Memory::new(ctx.job_id);
let long_reason = "x".repeat(5000);
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
assert!(fb.failure_reason.len() <= 1000); // safety: test
assert!(!fb.failure_reason.is_empty()); // safety: test
}
#[test]
fn test_truncate_str_ascii() {
assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
}
#[test]
fn test_truncate_str_unicode() {
// "é" is 2 bytes in UTF-8
let s = "café";
assert_eq!(truncate_str(s, 10), "café"); // safety: test
// Truncating at 4 would split "é", should back up to 3
assert_eq!(truncate_str(s, 4), "caf"); // safety: test
}
#[test]
fn test_fallback_serialization() {
let ctx = JobContext::new("Test", "Serialize");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
// Should serialize to JSON and back without error
let json = serde_json::to_value(&fb).unwrap(); // safety: test
let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
assert_eq!(deserialized.failure_reason, "test error"); // safety: test
}
}
+79 -70
View File
@@ -58,15 +58,19 @@ impl ActionRecord {
} }
/// Mark the action as successful. /// Mark the action as successful.
///
/// `output_sanitized` is the tool output after safety processing (string).
/// `output_raw` is the original tool result (JSON value, stored as a
/// pretty-printed JSON string in `ActionRecord.output_raw`).
pub fn succeed( pub fn succeed(
mut self, mut self,
output_raw: Option<String>, output_sanitized: Option<String>,
output_sanitized: serde_json::Value, output_raw: serde_json::Value,
duration: Duration, duration: Duration,
) -> Self { ) -> Self {
self.success = true; self.success = true;
self.output_raw = output_raw; self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
self.output_sanitized = Some(output_sanitized); self.output_sanitized = output_sanitized.map(serde_json::Value::String);
self.duration = duration; self.duration = duration;
self self
} }
@@ -248,15 +252,15 @@ mod tests {
#[test] #[test]
fn test_action_record() { fn test_action_record() {
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"})); let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
assert_eq!(action.sequence, 0); assert_eq!(action.sequence, 0); // safety: test
assert!(!action.success); assert!(!action.success); // safety: test
let action = action.succeed( let action = action.succeed(
Some("raw".to_string()), Some("raw".to_string()),
serde_json::json!({"result": "ok"}), serde_json::json!({"result": "ok"}),
Duration::from_millis(100), Duration::from_millis(100),
); );
assert!(action.success); assert!(action.success); // safety: test
} }
#[test] #[test]
@@ -267,7 +271,7 @@ mod tests {
memory.add(ChatMessage::user("How are you?")); memory.add(ChatMessage::user("How are you?"));
memory.add(ChatMessage::assistant("Good!")); memory.add(ChatMessage::assistant("Good!"));
assert_eq!(memory.len(), 3); // Oldest removed assert_eq!(memory.len(), 3); // Oldest removed // safety: test
} }
#[test] #[test]
@@ -286,9 +290,9 @@ mod tests {
.with_cost(Decimal::new(20, 1)); .with_cost(Decimal::new(20, 1));
memory.record_action(action2); memory.record_action(action2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
assert_eq!(memory.total_duration(), Duration::from_secs(3)); assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
assert_eq!(memory.successful_actions(), 2); assert_eq!(memory.successful_actions(), 2); // safety: test
} }
#[test] #[test]
@@ -296,11 +300,11 @@ mod tests {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1})); let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50)); let action = action.fail("something went wrong", Duration::from_millis(50));
assert!(!action.success); assert!(!action.success); // safety: test
assert_eq!(action.error.as_deref(), Some("something went wrong")); assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
assert_eq!(action.duration, Duration::from_millis(50)); assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
assert!(action.output_raw.is_none()); assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); assert!(action.output_sanitized.is_none()); // safety: test
} }
#[test] #[test]
@@ -308,9 +312,9 @@ mod tests {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({})); let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]); let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
assert_eq!(action.sanitization_warnings.len(), 2); assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
assert_eq!(action.sanitization_warnings[1], "possible xss"); assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
} }
#[test] #[test]
@@ -319,41 +323,46 @@ mod tests {
let cost = Decimal::new(42, 2); // 0.42 let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost); let action = action.with_cost(cost);
assert_eq!(action.cost, Some(Decimal::new(42, 2))); assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
} }
#[test] #[test]
fn test_action_record_new_defaults() { fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"})); let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
assert_eq!(action.sequence, 5); assert_eq!(action.sequence, 5); // safety: test
assert_eq!(action.tool_name, "my_tool"); assert_eq!(action.tool_name, "my_tool"); // safety: test
assert_eq!(action.input, serde_json::json!({"key": "val"})); assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
assert!(!action.success); assert!(!action.success); // safety: test
assert!(action.output_raw.is_none()); assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); assert!(action.output_sanitized.is_none()); // safety: test
assert!(action.sanitization_warnings.is_empty()); assert!(action.sanitization_warnings.is_empty()); // safety: test
assert!(action.cost.is_none()); assert!(action.cost.is_none()); // safety: test
assert_eq!(action.duration, Duration::ZERO); assert_eq!(action.duration, Duration::ZERO); // safety: test
assert!(action.error.is_none()); assert!(action.error.is_none()); // safety: test
} }
#[test] #[test]
fn test_action_record_succeed_sets_fields() { fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({})); let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed( let action = action.succeed(
Some("raw output here".into()), Some("sanitized output".into()),
serde_json::json!({"clean": true}), serde_json::json!({"clean": true}),
Duration::from_secs(7), Duration::from_secs(7),
); );
assert!(action.success); assert!(action.success); // safety: test
assert_eq!(action.output_raw.as_deref(), Some("raw output here")); // output_raw is the JSON value pretty-printed
let expected_raw =
serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
// output_sanitized wraps the string in a JSON string value
assert_eq!( assert_eq!(
/* safety: test */
action.output_sanitized, action.output_sanitized,
Some(serde_json::json!({"clean": true})) Some(serde_json::json!("sanitized output"))
); );
assert_eq!(action.duration, Duration::from_secs(7)); assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
} }
#[test] #[test]
@@ -361,13 +370,13 @@ mod tests {
let mut mem = ConversationMemory::new(10); let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello")); mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi")); mem.add(ChatMessage::assistant("hi"));
assert_eq!(mem.len(), 2); assert_eq!(mem.len(), 2); // safety: test
assert!(!mem.is_empty()); assert!(!mem.is_empty()); // safety: test
mem.clear(); mem.clear();
assert_eq!(mem.len(), 0); assert_eq!(mem.len(), 0); // safety: test
assert!(mem.is_empty()); assert!(mem.is_empty()); // safety: test
assert!(mem.messages().is_empty()); assert!(mem.messages().is_empty()); // safety: test
} }
#[test] #[test]
@@ -379,20 +388,20 @@ mod tests {
mem.add(ChatMessage::assistant("four")); mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2); let last_2 = mem.last_n(2);
assert_eq!(last_2.len(), 2); assert_eq!(last_2.len(), 2); // safety: test
assert_eq!(last_2[0].content, "three"); assert_eq!(last_2[0].content, "three"); // safety: test
assert_eq!(last_2[1].content, "four"); assert_eq!(last_2[1].content, "four"); // safety: test
// Requesting more than available returns all // Requesting more than available returns all
let last_100 = mem.last_n(100); let last_100 = mem.last_n(100);
assert_eq!(last_100.len(), 4); assert_eq!(last_100.len(), 4); // safety: test
} }
#[test] #[test]
fn test_conversation_memory_last_n_empty() { fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10); let mem = ConversationMemory::new(10);
let result = mem.last_n(5); let result = mem.last_n(5);
assert!(result.is_empty()); assert!(result.is_empty()); // safety: test
} }
#[test] #[test]
@@ -405,13 +414,13 @@ mod tests {
// At capacity (3). Adding one more should trim, but keep system. // At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3")); mem.add(ChatMessage::user("msg3"));
assert_eq!(mem.len(), 3); assert_eq!(mem.len(), 3); // safety: test
// System message must survive // System message must survive
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[0].content, "You are helpful"); assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
// Oldest non-system message (msg1) should be gone // Oldest non-system message (msg1) should be gone
assert_eq!(mem.messages()[1].content, "msg2"); assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
assert_eq!(mem.messages()[2].content, "msg3"); assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
} }
#[test] #[test]
@@ -422,9 +431,9 @@ mod tests {
// Now at capacity. Add another. // Now at capacity. Add another.
mem.add(ChatMessage::user("b")); mem.add(ChatMessage::user("b"));
assert_eq!(mem.len(), 2); assert_eq!(mem.len(), 2); // safety: test
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[1].content, "b"); assert_eq!(mem.messages()[1].content, "b"); // safety: test
} }
#[test] #[test]
@@ -440,7 +449,7 @@ mod tests {
mem.add(ChatMessage::user("hello")); mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever. // Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max. // The system message is protected, so len may exceed max.
assert!(mem.len() <= 2); assert!(mem.len() <= 2); // safety: test
} }
#[test] #[test]
@@ -459,14 +468,14 @@ mod tests {
.fail("oops", Duration::from_millis(2)); .fail("oops", Duration::from_millis(2));
memory.record_action(err); memory.record_action(err);
assert_eq!(memory.successful_actions(), 1); assert_eq!(memory.successful_actions(), 1); // safety: test
assert_eq!(memory.failed_actions(), 1); assert_eq!(memory.failed_actions(), 1); // safety: test
} }
#[test] #[test]
fn test_memory_last_action() { fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4()); let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.last_action().is_none()); assert!(memory.last_action().is_none()); // safety: test
let a1 = memory let a1 = memory
.create_action("first", serde_json::json!({})) .create_action("first", serde_json::json!({}))
@@ -478,8 +487,8 @@ mod tests {
.fail("nope", Duration::ZERO); .fail("nope", Duration::ZERO);
memory.record_action(a2); memory.record_action(a2);
let last = memory.last_action().unwrap(); let last = memory.last_action().unwrap(); // safety: test
assert_eq!(last.tool_name, "second"); assert_eq!(last.tool_name, "second"); // safety: test
} }
#[test] #[test]
@@ -499,9 +508,9 @@ mod tests {
); );
memory.record_action(a); memory.record_action(a);
assert_eq!(memory.actions_by_tool("shell").len(), 3); assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
assert_eq!(memory.actions_by_tool("http").len(), 1); assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
} }
#[test] #[test]
@@ -509,25 +518,25 @@ mod tests {
let mut memory = Memory::new(Uuid::new_v4()); let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({})); let a0 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a0.sequence, 0); assert_eq!(a0.sequence, 0); // safety: test
let a1 = memory.create_action("t", serde_json::json!({})); let a1 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a1.sequence, 1); assert_eq!(a1.sequence, 1); // safety: test
let a2 = memory.create_action("t", serde_json::json!({})); let a2 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a2.sequence, 2); assert_eq!(a2.sequence, 2); // safety: test
} }
#[test] #[test]
fn test_memory_add_message_delegates_to_conversation() { fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4()); let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.conversation.is_empty()); assert!(memory.conversation.is_empty()); // safety: test
memory.add_message(ChatMessage::user("hello")); memory.add_message(ChatMessage::user("hello"));
memory.add_message(ChatMessage::assistant("hi")); memory.add_message(ChatMessage::assistant("hi"));
assert_eq!(memory.conversation.len(), 2); assert_eq!(memory.conversation.len(), 2); // safety: test
assert_eq!(memory.conversation.messages()[0].content, "hello"); assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
} }
#[test] #[test]
@@ -540,7 +549,7 @@ mod tests {
.succeed(None, serde_json::json!({}), Duration::ZERO); .succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a); memory.record_action(a);
assert_eq!(memory.total_cost(), Decimal::ZERO); assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
} }
#[test] #[test]
@@ -560,6 +569,6 @@ mod tests {
memory.record_action(a2); memory.record_action(a2);
// Both successful and failed actions contribute to total duration // Both successful and failed actions contribute to total duration
assert_eq!(memory.total_duration(), Duration::from_millis(300)); assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
} }
} }
+2
View File
@@ -6,10 +6,12 @@
//! - State machine //! - State machine
//! - Resource tracking //! - Resource tracking
pub mod fallback;
mod manager; mod manager;
mod memory; mod memory;
mod state; mod state;
pub use fallback::FallbackDeliverable;
pub use manager::ContextManager; pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory}; pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded}; pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
+516 -220
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -39,9 +39,7 @@ pub enum OAuthCallbackError {
/// deployments where `127.0.0.1` is unreachable from the user's browser), /// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`. /// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String { pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT)) .unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
} }
@@ -57,7 +55,8 @@ pub fn callback_url() -> String {
/// Note: this transmits the session token over plain HTTP — prefer SSH port /// Note: this transmits the session token over plain HTTP — prefer SSH port
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible. /// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
pub fn callback_host() -> String { pub fn callback_host() -> String {
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) crate::config::helpers::env_or_override("OAUTH_CALLBACK_HOST")
.unwrap_or_else(|| "127.0.0.1".to_string())
} }
/// Returns `true` if `host` is a loopback address that only accepts local connections. /// Returns `true` if `host` is a loopback address that only accepts local connections.
+6
View File
@@ -333,6 +333,12 @@ async fn job_event_handler(
.get("session_id") .get("session_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()), .map(|s| s.to_string()),
// NOTE: `fallback_deliverable` is currently always None in SSE events.
// In-memory jobs store fallback data in JobContext.metadata (accessed via job_status tool).
// Sandbox containers don't yet emit fallback data in their event payloads.
// This field is forward-compatible infrastructure for when container workers
// gain context/memory tracking capabilities.
fallback_deliverable: payload.data.get("fallback_deliverable").cloned(),
}, },
_ => SseEvent::JobStatus { _ => SseEvent::JobStatus {
job_id: job_id_str, job_id: job_id_str,
+61 -15
View File
@@ -837,7 +837,7 @@ impl Tool for HttpTool {
})); }));
if has_credentials { if has_credentials {
return ApprovalRequirement::Always; return ApprovalRequirement::UnlessAutoApproved;
} }
// GET requests (or missing method, since GET is the default) are low-risk // GET requests (or missing method, since GET is the default) are low-risk
@@ -1093,25 +1093,31 @@ mod tests {
} }
#[test] #[test]
fn test_auth_header_object_format_returns_always() { fn test_auth_header_object_format_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data", "url": "https://api.example.com/data",
"headers": {"Authorization": "Bearer token123"} "headers": {"Authorization": "Bearer token123"}
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
fn test_auth_header_array_format_returns_always() { fn test_auth_header_array_format_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data", "url": "https://api.example.com/data",
"headers": [{"name": "Authorization", "value": "Bearer token123"}] "headers": [{"name": "Authorization", "value": "Bearer token123"}]
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1124,7 +1130,10 @@ mod tests {
"url": "https://example.com", "url": "https://example.com",
"headers": {"AUTHORIZATION": "Bearer x"} "headers": {"AUTHORIZATION": "Bearer x"}
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
// Array format with mixed case // Array format with mixed case
let params = serde_json::json!({ let params = serde_json::json!({
@@ -1132,7 +1141,10 @@ mod tests {
"url": "https://example.com", "url": "https://example.com",
"headers": [{"name": "X-Api-Key", "value": "key123"}] "headers": [{"name": "X-Api-Key", "value": "key123"}]
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1161,8 +1173,8 @@ mod tests {
}); });
assert_eq!( assert_eq!(
tool.requires_approval(&params), tool.requires_approval(&params),
ApprovalRequirement::Always, ApprovalRequirement::UnlessAutoApproved,
"Header '{}' should trigger Always approval", "Header '{}' should trigger UnlessAutoApproved approval",
header_name header_name
); );
} }
@@ -1203,7 +1215,7 @@ mod tests {
// ── Credential registry approval tests ───────────────────────────── // ── Credential registry approval tests ─────────────────────────────
#[test] #[test]
fn test_host_with_credential_mapping_returns_always() { fn test_host_with_credential_mapping_returns_unless_auto_approved() {
use crate::secrets::CredentialMapping; use crate::secrets::CredentialMapping;
use crate::tools::wasm::SharedCredentialRegistry; use crate::tools::wasm::SharedCredentialRegistry;
@@ -1223,7 +1235,10 @@ mod tests {
"method": "GET", "method": "GET",
"url": "https://api.openai.com/v1/models" "url": "https://api.openai.com/v1/models"
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1243,24 +1258,55 @@ mod tests {
} }
#[test] #[test]
fn test_url_query_param_credential_returns_always() { fn test_url_query_param_credential_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data?api_key=secret123" "url": "https://api.example.com/data?api_key=secret123"
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
fn test_bearer_value_in_custom_header_returns_always() { fn test_bearer_value_in_custom_header_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://example.com", "url": "https://example.com",
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")} "headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
/// Regression test: credentialed HTTP requests must return
/// `UnlessAutoApproved` (not `Always`) so that the session auto-approve
/// set is respected when the user says "always".
#[test]
fn test_credentialed_requests_respect_auto_approve() {
let tool = HttpTool::new();
// Manual credentials (Authorization header)
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/orgs/Casa",
"headers": {"Authorization": "Bearer ghp_abc123"}
});
// Must NOT be Always — Always ignores the session auto-approve set
assert_ne!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"Credentialed HTTP requests must not return Always; use UnlessAutoApproved"
);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved,
);
} }
#[test] #[test]
+181 -119
View File
@@ -1005,7 +1005,8 @@ impl Tool for JobStatusTool {
"created_at": job_ctx.created_at.to_rfc3339(), "created_at": job_ctx.created_at.to_rfc3339(),
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()), "started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()), "completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()),
"actual_cost": job_ctx.actual_cost.to_string() "actual_cost": job_ctx.actual_cost.to_string(),
"fallback_deliverable": job_ctx.metadata.get("fallback_deliverable"),
}); });
Ok(ToolOutput::success(result, start.elapsed())) Ok(ToolOutput::success(result, start.elapsed()))
} }
@@ -1384,7 +1385,7 @@ mod tests {
let tool = CreateJobTool::new(manager.clone()); let tool = CreateJobTool::new(manager.clone());
// Without sandbox deps, it should use the local path // Without sandbox deps, it should use the local path
assert!(!tool.sandbox_enabled()); assert!(!tool.sandbox_enabled()); // safety: test
let params = serde_json::json!({ let params = serde_json::json!({
"title": "Test Job", "title": "Test Job",
@@ -1392,12 +1393,13 @@ mod tests {
}); });
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); // safety: test
assert!(!job_id.is_empty()); assert!(!job_id.is_empty()); // safety: test
assert_eq!( assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"pending" "pending"
); );
} }
@@ -1409,11 +1411,11 @@ mod tests {
// Without sandbox // Without sandbox
let tool = CreateJobTool::new(Arc::clone(&manager)); let tool = CreateJobTool::new(Arc::clone(&manager));
let schema = tool.parameters_schema(); let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap(); let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(props.contains_key("title")); assert!(props.contains_key("title")); // safety: test
assert!(props.contains_key("description")); assert!(props.contains_key("description")); // safety: test
assert!(!props.contains_key("wait")); assert!(!props.contains_key("wait")); // safety: test
assert!(!props.contains_key("mode")); assert!(!props.contains_key("mode")); // safety: test
} }
#[test] #[test]
@@ -1422,7 +1424,7 @@ mod tests {
// Without sandbox: default timeout // Without sandbox: default timeout
let tool = CreateJobTool::new(Arc::clone(&manager)); let tool = CreateJobTool::new(Arc::clone(&manager));
assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1455,23 +1457,23 @@ mod tests {
let manager = Arc::new(ContextManager::new(5)); let manager = Arc::new(ContextManager::new(5));
// Create some jobs // Create some jobs
manager.create_job("Job 1", "Desc 1").await.unwrap(); manager.create_job("Job 1", "Desc 1").await.unwrap(); // safety: test
manager.create_job("Job 2", "Desc 2").await.unwrap(); manager.create_job("Job 2", "Desc 2").await.unwrap(); // safety: test
let tool = ListJobsTool::new(manager); let tool = ListJobsTool::new(manager);
let params = serde_json::json!({}); let params = serde_json::json!({});
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 2); assert_eq!(jobs.len(), 2); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_job_status_tool() { async fn test_job_status_tool() {
let manager = Arc::new(ContextManager::new(5)); let manager = Arc::new(ContextManager::new(5));
let job_id = manager.create_job("Test Job", "Description").await.unwrap(); let job_id = manager.create_job("Test Job", "Description").await.unwrap(); // safety: test
let tool = JobStatusTool::new(manager); let tool = JobStatusTool::new(manager);
@@ -1479,10 +1481,11 @@ mod tests {
"job_id": job_id.to_string() "job_id": job_id.to_string()
}); });
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!( assert_eq!(
result.result.get("title").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("title").unwrap().as_str().unwrap(), // safety: test
"Test Job" "Test Job"
); );
} }
@@ -1496,8 +1499,9 @@ mod tests {
let missing_title = tool let missing_title = tool
.execute(serde_json::json!({ "description": "A test job" }), &ctx) .execute(serde_json::json!({ "description": "A test job" }), &ctx)
.await; .await;
assert!(missing_title.is_err()); assert!(missing_title.is_err()); // safety: test
assert!( assert!(
/* safety: test */
missing_title missing_title
.unwrap_err() .unwrap_err()
.to_string() .to_string()
@@ -1507,8 +1511,9 @@ mod tests {
let missing_description = tool let missing_description = tool
.execute(serde_json::json!({ "title": "Test Job" }), &ctx) .execute(serde_json::json!({ "title": "Test Job" }), &ctx)
.await; .await;
assert!(missing_description.is_err()); assert!(missing_description.is_err()); // safety: test
assert!( assert!(
/* safety: test */
missing_description missing_description
.unwrap_err() .unwrap_err()
.to_string() .to_string()
@@ -1522,19 +1527,19 @@ mod tests {
let pending_id = manager let pending_id = manager
.create_job_for_user("default", "Pending Job", "Todo") .create_job_for_user("default", "Pending Job", "Todo")
.await .await
.unwrap(); .unwrap(); // safety: test
let completed_id = manager let completed_id = manager
.create_job_for_user("default", "Completed Job", "Done") .create_job_for_user("default", "Completed Job", "Done")
.await .await
.unwrap(); .unwrap(); // safety: test
let failed_id = manager let failed_id = manager
.create_job_for_user("default", "Failed Job", "Oops") .create_job_for_user("default", "Failed Job", "Oops")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.create_job_for_user("other-user", "Other User Job", "Ignore") .create_job_for_user("other-user", "Other User Job", "Ignore")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(completed_id, |ctx| { .update_context(completed_id, |ctx| {
@@ -1542,41 +1547,44 @@ mod tests {
ctx.transition_to(JobState::Completed, Some("done".to_string())) ctx.transition_to(JobState::Completed, Some("done".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(failed_id, |ctx| { .update_context(failed_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?; ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Failed, Some("boom".to_string())) ctx.transition_to(JobState::Failed, Some("boom".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = ListJobsTool::new(Arc::clone(&manager)); let tool = ListJobsTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap(); let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap(); // safety: test
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 3); assert_eq!(jobs.len(), 3); // safety: test
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string()) job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Pending") && job.get("status").and_then(|v| v.as_str()) == Some("Pending")
})); }));
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string()) job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Completed") && job.get("status").and_then(|v| v.as_str()) == Some("Completed")
})); }));
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string()) job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Failed") && job.get("status").and_then(|v| v.as_str()) == Some("Failed")
})); }));
let summary = result.result.get("summary").unwrap(); let summary = result.result.get("summary").unwrap(); // safety: test
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3)); assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3)); // safety: test
assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1)); assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1)); // safety: test
assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1)); assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1)); // safety: test
assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1)); assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1)); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1585,29 +1593,30 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Transition Job", "Track me") .create_job_for_user("default", "Transition Job", "Track me")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, Some("started".to_string()))?; ctx.transition_to(JobState::InProgress, Some("started".to_string()))?;
ctx.transition_to(JobState::Completed, Some("finished".to_string())) ctx.transition_to(JobState::Completed, Some("finished".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = JobStatusTool::new(Arc::clone(&manager)); let tool = JobStatusTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()), result.result.get("status").and_then(|v| v.as_str()),
Some("Completed") Some("Completed")
); );
assert!(result.result.get("started_at").unwrap().is_string()); assert!(result.result.get("started_at").unwrap().is_string()); // safety: test
assert!(result.result.get("completed_at").unwrap().is_string()); assert!(result.result.get("completed_at").unwrap().is_string()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1616,26 +1625,27 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Running Job", "In progress") .create_job_for_user("default", "Running Job", "In progress")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None)) .update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager)); let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()), result.result.get("status").and_then(|v| v.as_str()),
Some("cancelled") Some("cancelled")
); );
let updated = manager.get_context(job_id).await.unwrap(); let updated = manager.get_context(job_id).await.unwrap(); // safety: test
assert_eq!(updated.state, JobState::Cancelled); assert_eq!(updated.state, JobState::Cancelled); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1644,39 +1654,81 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Completed Job", "Already done") .create_job_for_user("default", "Completed Job", "Already done")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?; ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Completed, Some("done".to_string())) ctx.transition_to(JobState::Completed, Some("done".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager)); let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); // safety: test
assert!(error.contains("Cannot cancel job")); assert!(error.contains("Cannot cancel job")); // safety: test
assert!(error.contains("completed")); assert!(error.contains("completed")); // safety: test
}
#[tokio::test]
async fn test_job_status_includes_fallback_deliverable() {
let manager = Arc::new(ContextManager::new(5));
let job_id = manager
.create_job_for_user("default", "Failing Job", "Will fail")
.await
.unwrap(); // safety: test
// Inject a real FallbackDeliverable into the job metadata.
let fallback = serde_json::json!({
"partial": true,
"failure_reason": "max iterations",
"last_action": null,
"action_stats": { "total": 5, "successful": 3, "failed": 2 },
"tokens_used": 1000,
"cost": "0.05",
"elapsed_secs": 12.5,
"repair_attempts": 1,
});
manager
.update_context(job_id, |ctx| {
ctx.metadata = serde_json::json!({ "fallback_deliverable": fallback.clone() });
Ok::<(), String>(())
})
.await
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = JobStatusTool::new(manager);
let params = serde_json::json!({ "job_id": job_id.to_string() });
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let fb = result.result.get("fallback_deliverable").unwrap(); // safety: test
assert_eq!(fb.get("partial").unwrap(), true); // safety: test
assert_eq!(fb.get("failure_reason").unwrap(), "max iterations"); // safety: test
let stats = fb.get("action_stats").unwrap(); // safety: test
assert_eq!(stats.get("total").unwrap(), 5); // safety: test
assert_eq!(stats.get("successful").unwrap(), 3); // safety: test
assert_eq!(stats.get("failed").unwrap(), 2); // safety: test
} }
#[test] #[test]
fn test_resolve_project_dir_auto() { fn test_resolve_project_dir_auto() {
let project_id = Uuid::new_v4(); let project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); // safety: test
assert!(dir.exists()); assert!(dir.exists()); // safety: test
assert!(dir.ends_with(project_id.to_string())); assert!(dir.ends_with(project_id.to_string())); // safety: test
assert_eq!(browse_id, project_id.to_string()); assert_eq!(browse_id, project_id.to_string()); // safety: test
// Must be under the projects base // Must be under the projects base
let base = projects_base().canonicalize().unwrap(); let base = projects_base().canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&base)); assert!(dir.starts_with(&base)); // safety: test
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
@@ -1684,33 +1736,34 @@ mod tests {
#[test] #[test]
fn test_resolve_project_dir_explicit_under_base() { fn test_resolve_project_dir_explicit_under_base() {
let base = projects_base(); let base = projects_base();
std::fs::create_dir_all(&base).unwrap(); std::fs::create_dir_all(&base).unwrap(); // safety: test
let explicit = base.join("test_explicit_project"); let explicit = base.join("test_explicit_project");
// Explicit paths must already exist (no auto-create). // Explicit paths must already exist (no auto-create).
std::fs::create_dir_all(&explicit).unwrap(); std::fs::create_dir_all(&explicit).unwrap(); // safety: test
let project_id = Uuid::new_v4(); let project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); // safety: test
assert!(dir.exists()); assert!(dir.exists()); // safety: test
assert_eq!(browse_id, "test_explicit_project"); assert_eq!(browse_id, "test_explicit_project"); // safety: test
let canonical_base = base.canonicalize().unwrap(); let canonical_base = base.canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&canonical_base)); assert!(dir.starts_with(&canonical_base)); // safety: test
let _ = std::fs::remove_dir_all(&explicit); let _ = std::fs::remove_dir_all(&explicit);
} }
#[test] #[test]
fn test_resolve_project_dir_rejects_outside_base() { fn test_resolve_project_dir_rejects_outside_base() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap(); // safety: test
let escape_attempt = tmp.path().join("evil_project"); let escape_attempt = tmp.path().join("evil_project");
// Don't create it: explicit paths that don't exist are rejected // Don't create it: explicit paths that don't exist are rejected
// before the prefix check even runs. // before the prefix check even runs.
let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4()); let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4());
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("does not exist"), err.contains("does not exist"),
"expected 'does not exist' error, got: {}", "expected 'does not exist' error, got: {}",
err err
@@ -1720,13 +1773,14 @@ mod tests {
#[test] #[test]
fn test_resolve_project_dir_rejects_outside_base_existing() { fn test_resolve_project_dir_rejects_outside_base_existing() {
// A directory that exists but is outside the projects base. // A directory that exists but is outside the projects base.
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap(); // safety: test
let outside = tmp.path().to_path_buf(); let outside = tmp.path().to_path_buf();
let result = resolve_project_dir(Some(outside), Uuid::new_v4()); let result = resolve_project_dir(Some(outside), Uuid::new_v4());
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("must be under"), err.contains("must be under"),
"expected 'must be under' error, got: {}", "expected 'must be under' error, got: {}",
err err
@@ -1740,7 +1794,7 @@ mod tests {
let traversal = base.join("legit").join("..").join("..").join(".ssh"); let traversal = base.join("legit").join("..").join("..").join(".ssh");
let result = resolve_project_dir(Some(traversal), Uuid::new_v4()); let result = resolve_project_dir(Some(traversal), Uuid::new_v4());
assert!(result.is_err(), "traversal path should be rejected"); assert!(result.is_err(), "traversal path should be rejected"); // safety: test
// Traversal path that actually resolves gets the prefix check. // Traversal path that actually resolves gets the prefix check.
// `base/../` resolves to the parent of projects base, which is outside. // `base/../` resolves to the parent of projects base, which is outside.
@@ -1748,7 +1802,7 @@ mod tests {
std::fs::create_dir_all(&base_parent).ok(); std::fs::create_dir_all(&base_parent).ok();
if base_parent.exists() { if base_parent.exists() {
let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4()); let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4());
assert!(result.is_err(), "path outside base should be rejected"); assert!(result.is_err(), "path outside base should be rejected"); // safety: test
let _ = std::fs::remove_dir_all(&base_parent); let _ = std::fs::remove_dir_all(&base_parent);
} }
} }
@@ -1762,8 +1816,9 @@ mod tests {
)); ));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None); let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema(); let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap(); let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!( assert!(
/* safety: test */
props.contains_key("project_dir"), props.contains_key("project_dir"),
"sandbox schema must expose project_dir" "sandbox schema must expose project_dir"
); );
@@ -1778,8 +1833,9 @@ mod tests {
)); ));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None); let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema(); let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap(); let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!( assert!(
/* safety: test */
props.contains_key("credentials"), props.contains_key("credentials"),
"sandbox schema must expose credentials" "sandbox schema must expose credentials"
); );
@@ -1792,13 +1848,13 @@ mod tests {
// No credentials parameter // No credentials parameter
let params = serde_json::json!({"title": "t", "description": "d"}); let params = serde_json::json!({"title": "t", "description": "d"});
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); assert!(grants.is_empty()); // safety: test
// Empty credentials object // Empty credentials object
let params = serde_json::json!({"credentials": {}}); let params = serde_json::json!({"credentials": {}});
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); assert!(grants.is_empty()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1808,9 +1864,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}}); let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}});
let result = tool.parse_credentials(&params, "user1").await; let result = tool.parse_credentials(&params, "user1").await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("no secrets store"), err.contains("no secrets store"),
"expected 'no secrets store' error, got: {}", "expected 'no secrets store' error, got: {}",
err err
@@ -1828,9 +1885,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}}); let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}});
let result = tool.parse_credentials(&params, "user1").await; let result = tool.parse_credentials(&params, "user1").await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("not found"), err.contains("not found"),
"expected 'not found' error, got: {}", "expected 'not found' error, got: {}",
err err
@@ -1852,17 +1910,17 @@ mod tests {
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN), CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
) )
.await .await
.unwrap(); .unwrap(); // safety: test
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets)); let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
let params = serde_json::json!({ let params = serde_json::json!({
"credentials": {"github_token": "GITHUB_TOKEN"} "credentials": {"github_token": "GITHUB_TOKEN"}
}); });
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert_eq!(grants.len(), 1); assert_eq!(grants.len(), 1); // safety: test
assert_eq!(grants[0].secret_name, "github_token"); assert_eq!(grants[0].secret_name, "github_token"); // safety: test
assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); // safety: test
} }
fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool { fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool {
@@ -1876,7 +1934,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("default", "Test Job", "desc") .create_job_for_user("default", "Test Job", "desc")
.await .await
.unwrap(); .unwrap(); // safety: test
let queue: PromptQueue = let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -1889,18 +1947,19 @@ mod tests {
}); });
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!( assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"queued" "queued"
); );
let q = queue.lock().await; let q = queue.lock().await;
let prompts = q.get(&job_id).unwrap(); let prompts = q.get(&job_id).unwrap(); // safety: test
assert_eq!(prompts.len(), 1); assert_eq!(prompts.len(), 1); // safety: test
assert_eq!(prompts[0].content, "What's the status?"); assert_eq!(prompts[0].content, "What's the status?"); // safety: test
assert!(!prompts[0].done); assert!(!prompts[0].done); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1910,6 +1969,7 @@ mod tests {
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let tool = test_prompt_tool(queue); let tool = test_prompt_tool(queue);
assert_eq!( assert_eq!(
/* safety: test */
tool.requires_approval(&serde_json::json!({})), tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved ApprovalRequirement::UnlessAutoApproved
); );
@@ -1928,7 +1988,7 @@ mod tests {
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1943,7 +2003,7 @@ mod tests {
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1958,7 +2018,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("owner-user", "Secret Job", "classified") .create_job_for_user("owner-user", "Secret Job", "classified")
.await .await
.unwrap(); .unwrap(); // safety: test
// We need a Store to construct the tool, but creating one requires // We need a Store to construct the tool, but creating one requires
// a database URL. Instead, test the ownership logic directly: // a database URL. Instead, test the ownership logic directly:
@@ -1968,9 +2028,9 @@ mod tests {
..Default::default() ..Default::default()
}; };
let job_ctx = cm.get_context(job_id).await.unwrap(); let job_ctx = cm.get_context(job_id).await.unwrap(); // safety: test
assert_ne!(job_ctx.user_id, attacker_ctx.user_id); assert_ne!(job_ctx.user_id, attacker_ctx.user_id); // safety: test
assert_eq!(job_ctx.user_id, "owner-user"); assert_eq!(job_ctx.user_id, "owner-user"); // safety: test
} }
#[test] #[test]
@@ -1991,12 +2051,12 @@ mod tests {
"required": ["job_id"] "required": ["job_id"]
}); });
let props = schema.get("properties").unwrap().as_object().unwrap(); let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(props.contains_key("job_id")); assert!(props.contains_key("job_id")); // safety: test
assert!(props.contains_key("limit")); assert!(props.contains_key("limit")); // safety: test
let required = schema.get("required").unwrap().as_array().unwrap(); let required = schema.get("required").unwrap().as_array().unwrap(); // safety: test
assert_eq!(required.len(), 1); assert_eq!(required.len(), 1); // safety: test
assert_eq!(required[0].as_str().unwrap(), "job_id"); assert_eq!(required[0].as_str().unwrap(), "job_id"); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -2005,7 +2065,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("owner-user", "Test Job", "desc") .create_job_for_user("owner-user", "Test Job", "desc")
.await .await
.unwrap(); .unwrap(); // safety: test
let queue: PromptQueue = let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -2023,9 +2083,10 @@ mod tests {
}; };
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("does not belong to current user"), err.contains("does not belong to current user"),
"expected ownership error, got: {}", "expected ownership error, got: {}",
err err
@@ -2035,33 +2096,34 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_full_uuid() { async fn test_resolve_job_id_full_uuid() {
let cm = ContextManager::new(5); let cm = ContextManager::new(5);
let job_id = cm.create_job("Test", "Desc").await.unwrap(); let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap(); let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); assert_eq!(resolved, job_id); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_short_prefix() { async fn test_resolve_job_id_short_prefix() {
let cm = ContextManager::new(5); let cm = ContextManager::new(5);
let job_id = cm.create_job("Test", "Desc").await.unwrap(); let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
// Use first 8 hex chars (without dashes) // Use first 8 hex chars (without dashes)
let hex = job_id.to_string().replace('-', ""); let hex = job_id.to_string().replace('-', "");
let prefix = &hex[..8]; let prefix = &hex[..8];
let resolved = resolve_job_id(prefix, &cm).await.unwrap(); let resolved = resolve_job_id(prefix, &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); assert_eq!(resolved, job_id); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_no_match() { async fn test_resolve_job_id_no_match() {
let cm = ContextManager::new(5); let cm = ContextManager::new(5);
cm.create_job("Test", "Desc").await.unwrap(); cm.create_job("Test", "Desc").await.unwrap(); // safety: test
let result = resolve_job_id("00000000", &cm).await; let result = resolve_job_id("00000000", &cm).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("no job found"), err.contains("no job found"),
"expected 'no job found', got: {}", "expected 'no job found', got: {}",
err err
@@ -2072,6 +2134,6 @@ mod tests {
async fn test_resolve_job_id_invalid_input() { async fn test_resolve_job_id_invalid_input() {
let cm = ContextManager::new(5); let cm = ContextManager::new(5);
let result = resolve_job_id("not-hex-at-all!", &cm).await; let result = resolve_job_id("not-hex-at-all!", &cm).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
} }
} }
+149 -28
View File
@@ -196,6 +196,7 @@ impl Worker {
.get("session_id") .get("session_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()), .map(|s| s.to_string()),
fallback_deliverable: data.get("fallback_deliverable").cloned(),
}), }),
_ => None, _ => None,
}; };
@@ -960,9 +961,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
async fn mark_failed(&self, reason: &str) -> Result<(), Error> { async fn mark_failed(&self, reason: &str) -> Result<(), Error> {
// Build fallback deliverable from memory before transitioning.
let fallback = self.build_fallback(reason).await;
self.context_manager() self.context_manager()
.update_context(self.job_id, |ctx| { .update_context(self.job_id, |ctx| {
ctx.transition_to(JobState::Failed, Some(reason.to_string())) ctx.transition_to(JobState::Failed, Some(reason.to_string()))?;
store_fallback_in_metadata(ctx, fallback.as_ref());
Ok(())
}) })
.await? .await?
.map_err(|s| crate::error::JobError::ContextError { .map_err(|s| crate::error::JobError::ContextError {
@@ -983,8 +989,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
async fn mark_stuck(&self, reason: &str) -> Result<(), Error> { async fn mark_stuck(&self, reason: &str) -> Result<(), Error> {
// Build fallback deliverable from memory before transitioning.
let fallback = self.build_fallback(reason).await;
self.context_manager() self.context_manager()
.update_context(self.job_id, |ctx| ctx.mark_stuck(reason)) .update_context(self.job_id, |ctx| {
ctx.mark_stuck(reason)?;
store_fallback_in_metadata(ctx, fallback.as_ref());
Ok(())
})
.await? .await?
.map_err(|s| crate::error::JobError::ContextError { .map_err(|s| crate::error::JobError::ContextError {
id: self.job_id, id: self.job_id,
@@ -1002,6 +1015,57 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
self.persist_status(JobState::Stuck, Some(reason.to_string())); self.persist_status(JobState::Stuck, Some(reason.to_string()));
Ok(()) Ok(())
} }
/// Build a [`FallbackDeliverable`] from the current job context and memory.
async fn build_fallback(&self, reason: &str) -> Option<crate::context::FallbackDeliverable> {
let memory = match self.context_manager().get_memory(self.job_id).await {
Ok(memory) => memory,
Err(e) => {
tracing::warn!(
job_id = %self.job_id,
"Failed to load memory while building fallback deliverable: {e}"
);
return None;
}
};
let ctx = match self.context_manager().get_context(self.job_id).await {
Ok(ctx) => ctx,
Err(e) => {
tracing::warn!(
job_id = %self.job_id,
"Failed to load context while building fallback deliverable: {e}"
);
return None;
}
};
Some(crate::context::FallbackDeliverable::build(
&ctx, &memory, reason,
))
}
}
/// Store a fallback deliverable in the job context's metadata.
fn store_fallback_in_metadata(
ctx: &mut crate::context::JobContext,
fallback: Option<&crate::context::FallbackDeliverable>,
) {
let Some(fb) = fallback else {
return;
};
match serde_json::to_value(fb) {
Ok(val) => {
if !ctx.metadata.is_object() {
ctx.metadata = serde_json::json!({});
}
ctx.metadata["fallback_deliverable"] = val;
}
Err(e) => {
tracing::warn!(
"Failed to serialize fallback deliverable for job {}: {e}",
ctx.job_id
);
}
}
} }
/// Job delegate: implements `LoopDelegate` for the background job context. /// Job delegate: implements `LoopDelegate` for the background job context.
@@ -1440,7 +1504,7 @@ mod tests {
} }
let cm = Arc::new(crate::context::ContextManager::new(5)); let cm = Arc::new(crate::context::ContextManager::new(5));
let job_id = cm.create_job("test", "test job").await.unwrap(); let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
let deps = WorkerDeps { let deps = WorkerDeps {
context_manager: cm, context_manager: cm,
@@ -1472,8 +1536,9 @@ mod tests {
tool_call_id: "call_abc123".to_string(), tool_call_id: "call_abc123".to_string(),
}; };
assert_eq!(selection.tool_call_id, "call_abc123"); assert_eq!(selection.tool_call_id, "call_abc123"); // safety: test
assert_ne!( assert_ne!(
/* safety: test */
selection.tool_call_id, "tool_call_id", selection.tool_call_id, "tool_call_id",
"tool_call_id must not be the hardcoded placeholder string" "tool_call_id must not be the hardcoded placeholder string"
); );
@@ -1509,11 +1574,12 @@ mod tests {
let results = worker.execute_tools_parallel(&selections).await; let results = worker.execute_tools_parallel(&selections).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert_eq!(results.len(), 3); assert_eq!(results.len(), 3); // safety: test
for r in &results { for r in &results {
assert!(r.result.is_ok(), "Tool should succeed"); assert!(r.result.is_ok(), "Tool should succeed"); // safety: test
} }
assert!( assert!(
/* safety: test */
elapsed < Duration::from_millis(800), elapsed < Duration::from_millis(800),
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)", "Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
elapsed elapsed
@@ -1565,9 +1631,9 @@ mod tests {
let results = worker.execute_tools_parallel(&selections).await; let results = worker.execute_tools_parallel(&selections).await;
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); // safety: test
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); // safety: test
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1583,8 +1649,9 @@ mod tests {
}]; }];
let results = worker.execute_tools_parallel(&selections).await; let results = worker.execute_tools_parallel(&selections).await;
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1); // safety: test
assert!( assert!(
/* safety: test */
results[0].result.is_err(), results[0].result.is_err(),
"Missing tool should produce an error, not a panic" "Missing tool should produce an error, not a panic"
); );
@@ -1600,23 +1667,24 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
worker.mark_completed().await.unwrap(); worker.mark_completed().await.unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Completed); assert_eq!(ctx.state, JobState::Completed); // safety: test
// Second mark_completed should succeed (idempotent) rather than // Second mark_completed should succeed (idempotent) rather than
// erroring, matching the fix for the execution_loop / worker wrapper // erroring, matching the fix for the execution_loop / worker wrapper
// race condition. // race condition.
let result = worker.mark_completed().await; let result = worker.mark_completed().await;
assert!( assert!(
/* safety: test */
result.is_ok(), result.is_ok(),
"Completed -> Completed transition should be idempotent" "Completed -> Completed transition should be idempotent"
); );
@@ -1641,7 +1709,7 @@ mod tests {
} }
let cm = Arc::new(crate::context::ContextManager::new(5)); let cm = Arc::new(crate::context::ContextManager::new(5));
let job_id = cm.create_job("test", "test job").await.unwrap(); let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
let deps = WorkerDeps { let deps = WorkerDeps {
context_manager: cm, context_manager: cm,
@@ -1740,6 +1808,7 @@ mod tests {
.execute_tool("needs_approval", &serde_json::json!({})) .execute_tool("needs_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_err(), result.is_err(),
"Should be blocked without approval context" "Should be blocked without approval context"
); );
@@ -1752,7 +1821,7 @@ mod tests {
let result = worker_allowed let result = worker_allowed
.execute_tool("needs_approval", &serde_json::json!({})) .execute_tool("needs_approval", &serde_json::json!({}))
.await; .await;
assert!(result.is_ok(), "Should be allowed with autonomous context"); assert!(result.is_ok(), "Should be allowed with autonomous context"); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1766,6 +1835,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({})) .execute_tool("always_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_err(), result.is_err(),
"Always tool should be blocked without permission" "Always tool should be blocked without permission"
); );
@@ -1781,6 +1851,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({})) .execute_tool("always_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_ok(), result.is_ok(),
"Always tool should be allowed with permission" "Always tool should be allowed with permission"
); );
@@ -1797,8 +1868,8 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
// Set a token budget // Set a token budget
worker worker
@@ -1807,16 +1878,17 @@ mod tests {
ctx.max_tokens = 100; ctx.max_tokens = 100;
}) })
.await .await
.unwrap(); .unwrap(); // safety: test
// Simulate adding tokens that exceed the budget // Simulate adding tokens that exceed the budget
let budget_result = worker let budget_result = worker
.context_manager() .context_manager()
.update_context(worker.job_id, |ctx| ctx.add_tokens(200)) .update_context(worker.job_id, |ctx| ctx.add_tokens(200))
.await .await
.unwrap(); .unwrap(); // safety: test
assert!( assert!(
/* safety: test */
budget_result.is_err(), budget_result.is_err(),
"Should return error when token budget exceeded" "Should return error when token budget exceeded"
); );
@@ -1825,13 +1897,13 @@ mod tests {
worker worker
.mark_failed(&budget_result.unwrap_err().to_string()) .mark_failed(&budget_result.unwrap_err().to_string())
.await .await
.unwrap(); .unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Failed); assert_eq!(ctx.state, JobState::Failed); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1845,21 +1917,22 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
// Simulate what the execution loop does when max_iterations is exceeded // Simulate what the execution loop does when max_iterations is exceeded
worker worker
.mark_failed("Maximum iterations exceeded: job hit the iteration cap") .mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await .await
.unwrap(); .unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
ctx.state, ctx.state,
JobState::Failed, JobState::Failed,
"Iteration cap should transition to Failed, not Stuck" "Iteration cap should transition to Failed, not Stuck"
@@ -1989,4 +2062,52 @@ mod tests {
"Should skip empty first reasoning and return the first non-empty one" "Should skip empty first reasoning and return the first non-empty one"
); );
} }
#[test]
fn test_store_fallback_in_metadata_roundtrip() {
use crate::context::FallbackDeliverable;
let mut ctx = JobContext::new("Test", "fallback roundtrip");
let memory = crate::context::Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test failure");
// Store into metadata
store_fallback_in_metadata(&mut ctx, Some(&fb));
// Verify it's stored and can be deserialized back
let stored = ctx.metadata.get("fallback_deliverable");
assert!(stored.is_some(), "fallback missing from metadata"); // safety: test
let recovered: FallbackDeliverable =
serde_json::from_value(stored.unwrap().clone()).expect("deserialize fallback"); // safety: test
assert_eq!(recovered.failure_reason, "test failure"); // safety: test
assert!(!recovered.partial); // safety: test
}
#[test]
fn test_store_fallback_handles_non_object_metadata() {
use crate::context::FallbackDeliverable;
let mut ctx = JobContext::new("Test", "non-object metadata");
ctx.metadata = serde_json::json!("not an object");
let memory = crate::context::Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
store_fallback_in_metadata(&mut ctx, Some(&fb));
// Must normalize to object and store
assert!(ctx.metadata.is_object()); // safety: test
assert!(ctx.metadata.get("fallback_deliverable").is_some()); // safety: test
}
#[test]
fn test_store_fallback_none_is_noop() {
let mut ctx = JobContext::new("Test", "noop");
let original = ctx.metadata.clone();
store_fallback_in_metadata(&mut ctx, None);
assert_eq!(ctx.metadata, original); // safety: test
}
} }
+6 -1
View File
@@ -38,12 +38,17 @@ workspace/
## Using the Workspace ## Using the Workspace
```rust ```rust
use std::sync::Arc;
use crate::workspace::{Workspace, OpenAiEmbeddings, paths}; use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
// Create workspace for a user // Create workspace for a user (wraps embeddings in a default LRU cache)
let workspace = Workspace::new("user_123", pool) let workspace = Workspace::new("user_123", pool)
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key))); .with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
// For tests: skip the cache layer (avoids unnecessary overhead with mocks)
// let workspace = Workspace::new("user_123", pool)
// .with_embeddings_uncached(Arc::new(MockEmbeddings::new(1536)));
// Read/write any path // Read/write any path
let doc = workspace.read("projects/alpha/notes.md").await?; let doc = workspace.read("projects/alpha/notes.md").await?;
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?; workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
+613
View File
@@ -0,0 +1,613 @@
//! LRU embedding cache wrapping any [`EmbeddingProvider`].
//!
//! Avoids redundant HTTP calls for identical texts by caching embeddings
//! in memory keyed by `SHA-256(model_name + "\0" + text)`.
//!
//! Follows the same cache pattern as `llm::response_cache::CachedProvider`:
//! `HashMap` + `last_accessed` tracking + manual LRU eviction.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::workspace::embeddings::{EmbeddingError, EmbeddingProvider};
/// Configuration for the embedding cache.
#[derive(Debug, Clone)]
pub struct EmbeddingCacheConfig {
/// Maximum number of cached embeddings (default 10,000).
///
/// Approximate raw embedding payload: `max_entries × dimension × 4 bytes`.
/// At 10,000 entries × 1536 floats ≈ 58 MB (payload only; actual memory
/// is higher due to HashMap buckets, `[u8; 32]` hash keys, `Vec`/`Instant`
/// per-entry overhead).
pub max_entries: usize,
}
impl Default for EmbeddingCacheConfig {
fn default() -> Self {
Self {
max_entries: crate::config::DEFAULT_EMBEDDING_CACHE_SIZE,
}
}
}
struct CacheEntry {
embedding: Vec<f32>,
last_accessed: Instant,
}
/// Embedding provider wrapper that caches results in memory.
///
/// Thread-safe via `std::sync::Mutex`. The lock is **never held**
/// across `.await` points (all critical sections are scoped blocks),
/// so a synchronous mutex is cheaper than `tokio::sync::Mutex`.
pub struct CachedEmbeddingProvider {
inner: Arc<dyn EmbeddingProvider>,
cache: Mutex<HashMap<[u8; 32], CacheEntry>>,
config: EmbeddingCacheConfig,
}
impl CachedEmbeddingProvider {
/// Wrap a provider with LRU caching.
///
/// `config.max_entries` is clamped to at least 1.
pub fn new(inner: Arc<dyn EmbeddingProvider>, config: EmbeddingCacheConfig) -> Self {
let config = EmbeddingCacheConfig {
max_entries: config.max_entries.max(1),
};
if config.max_entries > 100_000 {
tracing::warn!(
max_entries = config.max_entries,
"Embedding cache size exceeds 100,000 entries; memory usage may be significant"
);
}
Self {
inner,
cache: Mutex::new(HashMap::with_capacity(config.max_entries.min(1024))),
config,
}
}
/// Number of entries currently in the cache.
pub fn len(&self) -> usize {
self.cache.lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Whether the cache is empty.
pub fn is_empty(&self) -> bool {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
/// Clear all cached entries.
pub fn clear(&self) {
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
/// Build a deterministic cache key: `SHA-256(model_name + "\0" + text)`.
///
/// Returns raw 32-byte hash to avoid a 64-char hex String allocation per lookup.
fn cache_key(&self, text: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.inner.model_name().as_bytes());
hasher.update(b"\0");
hasher.update(text.as_bytes());
hasher.finalize().into()
}
/// Evict the least-recently-used entry if at capacity (single-entry path).
// TODO: O(n) scan per eviction. If max_entries grows large, switch to
// an ordered data structure (e.g. `IndexMap` with swap_remove, or a
// linked-list LRU like the `lru` crate).
fn evict_lru(cache: &mut HashMap<[u8; 32], CacheEntry>, max_entries: usize) {
while cache.len() >= max_entries {
let oldest_key = cache
.iter()
.min_by_key(|(_, entry)| entry.last_accessed)
.map(|(k, _)| *k);
if let Some(k) = oldest_key {
cache.remove(&k);
} else {
break;
}
}
}
/// Evict the `k` oldest entries in O(n) average time via partial selection.
///
/// Used by `embed_batch` to avoid the O(n×m) cost of calling
/// `evict_lru` per insert.
fn evict_k_oldest(cache: &mut HashMap<[u8; 32], CacheEntry>, k: usize) {
if k == 0 || cache.is_empty() {
return;
}
if k >= cache.len() {
cache.clear();
return;
}
// Partial selection: find the k oldest in O(n) average via
// select_nth_unstable_by_key, then remove the first k entries.
let mut entries: Vec<([u8; 32], Instant)> = cache
.iter()
.map(|(key, entry)| (*key, entry.last_accessed))
.collect();
entries.select_nth_unstable_by_key(k - 1, |(_, t)| *t);
for (key, _) in entries.into_iter().take(k) {
cache.remove(&key);
}
}
}
#[async_trait]
impl EmbeddingProvider for CachedEmbeddingProvider {
fn dimension(&self) -> usize {
self.inner.dimension()
}
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn max_input_length(&self) -> usize {
self.inner.max_input_length()
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
let key = self.cache_key(text);
// Check cache (short critical section)
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get_mut(&key) {
entry.last_accessed = Instant::now();
tracing::trace!("embedding cache hit");
return Ok(entry.embedding.clone());
}
}
// Lock released before HTTP call.
// NOTE: Thundering herd — multiple concurrent callers with the same
// uncached key will each call the inner provider. This is acceptable:
// embeddings are idempotent and the last writer wins in the HashMap.
let embedding = self.inner.embed(text).await?;
// Store result. Re-check under lock: another concurrent caller may
// have inserted this key while the lock was released for the HTTP call.
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get_mut(&key) {
// Key already present (thundering herd) — just update, no eviction needed.
entry.embedding = embedding.clone();
entry.last_accessed = Instant::now();
} else {
Self::evict_lru(&mut guard, self.config.max_entries);
guard.insert(
key,
CacheEntry {
embedding: embedding.clone(),
last_accessed: Instant::now(),
},
);
}
}
tracing::trace!("embedding cache miss");
Ok(embedding)
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
// Partition into hits and misses
let keys: Vec<[u8; 32]> = texts.iter().map(|t| self.cache_key(t)).collect();
let mut results: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
let mut miss_indices: Vec<usize> = Vec::new();
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
for (i, key) in keys.iter().enumerate() {
if let Some(entry) = guard.get_mut(key) {
entry.last_accessed = now;
results[i] = Some(entry.embedding.clone());
} else {
miss_indices.push(i);
}
}
}
// Lock released before HTTP call
if miss_indices.is_empty() {
tracing::trace!(count = texts.len(), "embedding batch: all cache hits");
// All slots populated from cache hits
return results
.into_iter()
.enumerate()
.map(|(i, slot)| {
slot.ok_or_else(|| {
EmbeddingError::InvalidResponse(format!(
"embedding slot {i} was not populated"
))
})
})
.collect::<Result<Vec<_>, _>>();
}
// Fetch missing embeddings
let miss_texts: Vec<String> = miss_indices.iter().map(|&i| texts[i].clone()).collect();
let new_embeddings = self.inner.embed_batch(&miss_texts).await?;
if new_embeddings.len() != miss_indices.len() {
return Err(EmbeddingError::InvalidResponse(format!(
"embed_batch returned {} embeddings, expected {}",
new_embeddings.len(),
miss_indices.len()
)));
}
tracing::trace!(
hits = texts.len() - miss_indices.len(),
misses = miss_indices.len(),
"embedding batch: partial cache"
);
// Assemble results first (all misses, regardless of cache capacity).
for (orig_idx, emb) in miss_indices.iter().copied().zip(&new_embeddings) {
results[orig_idx] = Some(emb.clone());
}
// Cache the new embeddings, respecting max_entries.
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
// When misses exceed capacity, clear and only cache the tail.
let cacheable = miss_indices.len().min(self.config.max_entries);
let skip = miss_indices.len() - cacheable;
let need_to_evict = (guard.len() + cacheable).saturating_sub(self.config.max_entries);
if need_to_evict > 0 {
Self::evict_k_oldest(&mut guard, need_to_evict);
}
let now = Instant::now();
for (&orig_idx, emb) in miss_indices[skip..].iter().zip(&new_embeddings[skip..]) {
guard.insert(
keys[orig_idx],
CacheEntry {
embedding: emb.clone(),
last_accessed: now,
},
);
}
}
results
.into_iter()
.enumerate()
.map(|(i, slot)| {
slot.ok_or_else(|| {
EmbeddingError::InvalidResponse(format!("embedding slot {i} was not populated"))
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
/// Mock embedding provider that counts calls.
struct CountingMock {
dimension: usize,
model: String,
embed_calls: AtomicU32,
batch_calls: AtomicU32,
}
impl CountingMock {
fn new(dimension: usize, model: &str) -> Self {
Self {
dimension,
model: model.to_string(),
embed_calls: AtomicU32::new(0),
batch_calls: AtomicU32::new(0),
}
}
fn embed_calls(&self) -> u32 {
self.embed_calls.load(Ordering::SeqCst)
}
fn batch_calls(&self) -> u32 {
self.batch_calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl EmbeddingProvider for CountingMock {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
10_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
self.embed_calls.fetch_add(1, Ordering::SeqCst);
// Simple deterministic embedding: val = text.len() / 100.0
let val = text.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
self.batch_calls.fetch_add(1, Ordering::SeqCst);
texts
.iter()
.map(|t| {
let val = t.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
})
.collect()
}
}
#[tokio::test]
async fn cache_hit_avoids_inner_call() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
let r1 = cached.embed("hello").await.unwrap();
assert_eq!(inner.embed_calls(), 1);
let r2 = cached.embed("hello").await.unwrap();
assert_eq!(inner.embed_calls(), 1); // still 1 -- cache hit
assert_eq!(r1, r2);
assert_eq!(cached.len(), 1);
}
#[tokio::test]
async fn cache_miss_calls_inner() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
cached.embed("hello").await.unwrap();
cached.embed("world").await.unwrap();
assert_eq!(inner.embed_calls(), 2);
assert_eq!(cached.len(), 2);
}
#[tokio::test]
async fn cache_key_includes_model() {
let inner_a = Arc::new(CountingMock::new(4, "model-a"));
let inner_b = Arc::new(CountingMock::new(4, "model-b"));
let cached_a = CachedEmbeddingProvider::new(
inner_a.clone(),
EmbeddingCacheConfig { max_entries: 100 },
);
let cached_b = CachedEmbeddingProvider::new(
inner_b.clone(),
EmbeddingCacheConfig { max_entries: 100 },
);
// Same text, different models -> different cache keys
let key_a = cached_a.cache_key("hello");
let key_b = cached_b.cache_key("hello");
assert_ne!(key_a, key_b);
}
#[tokio::test]
async fn lru_eviction() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 2 });
cached.embed("first").await.unwrap();
cached.embed("second").await.unwrap();
assert_eq!(cached.len(), 2);
// Third entry should evict the oldest ("first")
cached.embed("third").await.unwrap();
assert_eq!(cached.len(), 2);
assert_eq!(inner.embed_calls(), 3);
// "first" should be a cache miss now
cached.embed("first").await.unwrap();
assert_eq!(inner.embed_calls(), 4);
}
#[tokio::test]
async fn embed_batch_partial_hits() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Pre-cache one text
cached.embed("cached").await.unwrap();
assert_eq!(inner.embed_calls(), 1);
// Batch with 1 cached + 2 new
let texts = vec![
"cached".to_string(),
"new_one".to_string(),
"new_two".to_string(),
];
let results = cached.embed_batch(&texts).await.unwrap();
// Should have called embed_batch on inner for 2 misses
assert_eq!(inner.batch_calls(), 1);
assert_eq!(results.len(), 3);
assert_eq!(cached.len(), 3);
}
#[tokio::test]
async fn batch_preserves_order() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Pre-cache "bb" (len 2)
cached.embed("bb").await.unwrap();
// Batch: "a" (miss, len 1), "bb" (hit, len 2), "ccc" (miss, len 3)
let texts = vec!["a".to_string(), "bb".to_string(), "ccc".to_string()];
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 3);
let expected_a = vec![1.0_f32 / 100.0; 4];
let expected_bb = vec![2.0_f32 / 100.0; 4];
let expected_ccc = vec![3.0_f32 / 100.0; 4];
assert_eq!(results[0], expected_a);
assert_eq!(results[1], expected_bb);
assert_eq!(results[2], expected_ccc);
}
#[tokio::test]
async fn batch_exceeding_capacity_respects_max_entries() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 3 });
// Batch with 5 misses but cache capacity is 3
let texts: Vec<String> = (0..5).map(|i| format!("text_{i}")).collect();
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 5);
let len = cached.len();
assert!(len <= 3, "cache len {len} exceeds max 3");
}
/// Mock embedding provider that fails the first N calls, then succeeds.
struct FailThenSucceedMock {
dimension: usize,
model: String,
remaining_failures: AtomicU32,
}
impl FailThenSucceedMock {
fn new(dimension: usize, fail_count: u32) -> Self {
Self {
dimension,
model: "fail-mock".to_string(),
remaining_failures: AtomicU32::new(fail_count),
}
}
}
#[async_trait]
impl EmbeddingProvider for FailThenSucceedMock {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
10_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
let prev =
self.remaining_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
if v > 0 { Some(v - 1) } else { None }
});
if prev.is_ok() {
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
}
let val = text.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let prev =
self.remaining_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
if v > 0 { Some(v - 1) } else { None }
});
if prev.is_ok() {
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
}
texts
.iter()
.map(|t| {
let val = t.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
})
.collect()
}
}
#[tokio::test]
async fn error_does_not_pollute_cache() {
let inner = Arc::new(FailThenSucceedMock::new(4, 1));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// First call fails
let err = cached.embed("hello").await;
assert!(err.is_err());
assert!(cached.is_empty(), "cache should be empty after error");
// Second call succeeds and should call the inner provider (not serve stale error)
let result = cached.embed("hello").await;
assert!(result.is_ok());
assert_eq!(cached.len(), 1);
}
#[tokio::test]
async fn embed_batch_empty_input() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
let results = cached.embed_batch(&[]).await.unwrap();
assert!(results.is_empty());
assert_eq!(inner.batch_calls(), 0);
}
#[tokio::test]
async fn embed_batch_all_misses() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Nothing cached — every text is a miss
let texts: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()];
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(inner.batch_calls(), 1, "inner called once for misses");
assert_eq!(cached.len(), 3, "all results should be cached");
// Second call should be all hits — no new inner calls
let results2 = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results2.len(), 3);
assert_eq!(inner.batch_calls(), 1, "no new inner calls");
}
#[tokio::test]
async fn zero_max_entries_clamped_to_one() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 0 });
// Should behave as max_entries=1 (clamped in constructor)
cached.embed("hello").await.unwrap();
assert_eq!(cached.len(), 1);
// Second entry evicts the first
cached.embed("world").await.unwrap();
assert_eq!(cached.len(), 1);
assert_eq!(inner.embed_calls(), 2);
}
}
+28
View File
@@ -42,6 +42,7 @@
mod chunker; mod chunker;
mod document; mod document;
mod embedding_cache;
mod embeddings; mod embeddings;
pub mod hygiene; pub mod hygiene;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
@@ -50,6 +51,7 @@ mod search;
pub use chunker::{ChunkConfig, chunk_document}; pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths}; pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{ pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings, EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
}; };
@@ -371,7 +373,33 @@ impl Workspace {
} }
/// Set the embedding provider for semantic search. /// Set the embedding provider for semantic search.
///
/// The provider is automatically wrapped in a [`CachedEmbeddingProvider`]
/// with the default cache size (10,000 entries; payload ~58 MB for 1536-dim,
/// actual memory higher due to per-entry overhead).
pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self { pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
provider,
EmbeddingCacheConfig::default(),
)));
self
}
/// Set the embedding provider with a custom cache configuration.
pub fn with_embeddings_cached(
mut self,
provider: Arc<dyn EmbeddingProvider>,
cache_config: EmbeddingCacheConfig,
) -> Self {
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
provider,
cache_config,
)));
self
}
/// Set the embedding provider **without** caching (for tests).
pub fn with_embeddings_uncached(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(provider); self.embeddings = Some(provider);
self self
} }
+14 -4
View File
@@ -267,14 +267,24 @@ async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.Str
async def oauth_exchange(request: web.Request) -> web.Response: async def oauth_exchange(request: web.Request) -> web.Response:
"""Mock OAuth token exchange proxy for E2E tests. """Mock OAuth token exchange proxy for E2E tests.
Accepts form params (code, redirect_uri, code_verifier) and returns Accepts the generic hosted OAuth proxy contract used by IronClaw and
a fake token response. Called by ironclaw's exchange_via_proxy() when returns a fake token response. MCP callback tests assert that provider-
IRONCLAW_OAUTH_EXCHANGE_URL is set. specific token params such as RFC 8707 `resource` are forwarded here.
""" """
data = await request.post() data = await request.post()
code = data.get("code", "") code = data.get("code", "")
access_token_field = data.get("access_token_field", "access_token")
if code == "mock_mcp_code":
if not data.get("token_url", "").endswith("/oauth/token"):
return web.json_response({"error": "missing_token_url"}, status=400)
if not data.get("client_id"):
return web.json_response({"error": "missing_client_id"}, status=400)
if not data.get("resource"):
return web.json_response({"error": "missing_resource"}, status=400)
return web.json_response({ return web.json_response({
"access_token": f"mock-token-{code}", access_token_field: f"mock-token-{code}",
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"expires_in": 3600, "expires_in": 3600,
}) })
@@ -99,6 +99,10 @@ async def test_mcp_activate_triggers_auth(ironclaw_server):
assert auth_url is not None or awaiting_token, ( assert auth_url is not None or awaiting_token, (
f"Activate should require auth, got: {data}" f"Activate should require auth, got: {data}"
) )
if auth_url is not None:
assert _extract_state(auth_url).startswith("ic2."), (
f"Hosted MCP OAuth should emit versioned state, got: {auth_url}"
)
# ── Section C: OAuth Round-Trip ────────────────────────────────────────── # ── Section C: OAuth Round-Trip ──────────────────────────────────────────
+71 -179
View File
@@ -2,18 +2,12 @@
//! //!
//! Uses real HTTP servers on random ports (no mock framework). //! Uses real HTTP servers on random ports (no mock framework).
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::Query, extract::Query,
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
routing::{get, post}, routing::{get, post},
}; };
use futures::stream; use ironclaw::channels::relay::client::{ChannelEvent, RelayClient};
use ironclaw::channels::relay::client::{RelayClient, RelayError};
use secrecy::SecretString; use secrecy::SecretString;
use serde::Deserialize; use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -37,109 +31,79 @@ fn test_client(base_url: &str) -> RelayClient {
.expect("client build") .expect("client build")
} }
// ── SSE stream mock ───────────────────────────────────────────────────── // ── Signing secret fetch ─────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn test_sse_stream_receives_events() { async fn test_get_signing_secret_returns_decoded_bytes() {
let secret_hex = hex::encode([1u8; 32]);
let secret_hex_clone = secret_hex.clone();
let app = Router::new().route( let app = Router::new().route(
"/stream", "/relay/signing-secret",
get( get(move || {
|Query(params): Query<std::collections::HashMap<String, String>>| async move { let s = secret_hex_clone.clone();
// Verify token is passed async move { Json(serde_json::json!({"signing_secret": s})) }
assert!(params.contains_key("token"));
let events = vec![
Ok::<_, Infallible>(
Event::default().event("message").data(
serde_json::json!({
"event_type": "message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "C456",
"sender_id": "U789",
"content": "hello world"
})
.to_string(),
),
),
Ok(Event::default().event("message").data(
serde_json::json!({
"event_type": "direct_message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "D001",
"sender_id": "U789",
"content": "dm text"
})
.to_string(),
)),
];
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
},
),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let (mut event_stream, handle) = client.connect_stream("test-token", 30).await.unwrap();
use futures::StreamExt;
let first = event_stream.next().await.expect("first event");
assert_eq!(first.event_type, "message");
assert_eq!(first.text(), "hello world");
assert_eq!(first.team_id(), "T123");
let second = event_stream.next().await.expect("second event");
assert_eq!(second.event_type, "direct_message");
assert_eq!(second.text(), "dm text");
handle.abort();
}
// ── Token renewal flow ──────────────────────────────────────────────────
#[tokio::test]
async fn test_token_expired_returns_error() {
let app = Router::new().route("/stream", get(|| async { StatusCode::UNAUTHORIZED }));
let base_url = start_server(app).await;
let client = test_client(&base_url);
match client.connect_stream("expired-token", 30).await {
Err(RelayError::TokenExpired) => {} // expected
Err(other) => panic!("expected TokenExpired, got: {other}"),
Ok(_) => panic!("expected error, got Ok"),
}
}
#[tokio::test]
async fn test_token_renewal() {
let call_count = std::sync::Arc::new(AtomicUsize::new(0));
let call_count_clone = call_count.clone();
let app = Router::new().route(
"/stream/renew",
post(move |Json(body): Json<serde_json::Value>| {
let count = call_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
assert!(body.get("instance_id").is_some());
assert!(body.get("user_id").is_some());
Json(serde_json::json!({
"stream_token": "renewed-token-123"
}))
}
}), }),
); );
let base_url = start_server(app).await; let base_url = start_server(app).await;
let client = test_client(&base_url); let client = test_client(&base_url);
let new_token = client.renew_token("inst-1", "user-1").await.unwrap(); let secret = client.get_signing_secret("T123").await.unwrap();
assert_eq!(new_token, "renewed-token-123"); assert_eq!(secret, vec![1u8; 32]);
assert_eq!(call_count.load(Ordering::SeqCst), 1); }
#[tokio::test]
async fn test_get_signing_secret_404_returns_error() {
let app = Router::new().route(
"/relay/signing-secret",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") }),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let result = client.get_signing_secret("T123").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_signing_secret_invalid_hex_returns_protocol_error() {
let app = Router::new().route(
"/relay/signing-secret",
get(|| async { Json(serde_json::json!({"signing_secret": "not-hex"})) }),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let err = client
.get_signing_secret("T123")
.await
.unwrap_err()
.to_string();
assert!(err.contains("invalid signing_secret hex"), "got: {err}");
}
#[tokio::test]
async fn test_get_signing_secret_wrong_length_returns_protocol_error() {
let short_secret_hex = hex::encode([7u8; 31]);
let app = Router::new().route(
"/relay/signing-secret",
get(move || {
let s = short_secret_hex.clone();
async move { Json(serde_json::json!({"signing_secret": s})) }
}),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let err = client
.get_signing_secret("T123")
.await
.unwrap_err()
.to_string();
assert!(err.contains("expected 32 bytes"), "got: {err}");
} }
// ── Proxy call ────────────────────────────────────────────────────────── // ── Proxy call ──────────────────────────────────────────────────────────
@@ -171,7 +135,7 @@ async fn test_proxy_provider_sends_correct_payload() {
"text": "Hello from test", "text": "Hello from test",
}); });
let resp = client let resp = client
.proxy_provider("slack", "T123", "chat.postMessage", body, None) .proxy_provider("slack", "T123", "chat.postMessage", body)
.await .await
.unwrap(); .unwrap();
assert_eq!(resp["ok"], true); assert_eq!(resp["ok"], true);
@@ -200,18 +164,18 @@ async fn test_list_connections() {
assert!(!conns[1].connected); assert!(!conns[1].connected);
} }
// ── API key header ────────────────────────────────────────────────────── // ── Bearer token auth ────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn test_api_key_sent_in_header() { async fn test_bearer_token_sent_in_header() {
let app = Router::new().route( let app = Router::new().route(
"/connections", "/connections",
get(|headers: axum::http::HeaderMap| async move { get(|headers: axum::http::HeaderMap| async move {
let key = headers let auth = headers
.get("X-API-Key") .get("authorization")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .unwrap_or("");
assert_eq!(key, "test-api-key"); assert_eq!(auth, "Bearer test-api-key");
Json(serde_json::json!([])) Json(serde_json::json!([]))
}), }),
); );
@@ -233,82 +197,10 @@ fn test_relay_client_new_succeeds() {
assert!(client.is_ok()); assert!(client.is_ok());
} }
// ── SSE UTF-8 chunk boundary ────────────────────────────────────────────
/// Verify that multi-byte UTF-8 characters split across SSE chunks are
/// not corrupted (no U+FFFD replacement characters).
#[tokio::test]
async fn test_sse_stream_preserves_multibyte_utf8_across_chunks() {
use std::sync::atomic::{AtomicBool, Ordering};
let sent = std::sync::Arc::new(AtomicBool::new(false));
let sent_clone = sent.clone();
let app = Router::new().route(
"/stream",
get(move |_: Query<std::collections::HashMap<String, String>>| {
let sent = sent_clone.clone();
async move {
// Build SSE payload with emoji that will be split mid-character
let event_data = serde_json::json!({
"event_type": "message",
"provider": "slack",
"provider_scope": "T1",
"channel_id": "C1",
"sender_id": "U1",
"content": "hello 🦀 world"
});
let payload = format!("event: message\ndata: {}\n\n", event_data);
let bytes = payload.into_bytes();
// Split in the middle of the 4-byte crab emoji
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.unwrap();
let split_at = crab_pos + 2;
let chunk1 = bytes[..split_at].to_vec();
let chunk2 = bytes[split_at..].to_vec();
sent.store(true, Ordering::SeqCst);
let events = vec![
Ok::<_, Infallible>(axum::body::Bytes::from(chunk1)),
Ok(axum::body::Bytes::from(chunk2)),
];
axum::response::Response::builder()
.header("content-type", "text/event-stream")
.body(axum::body::Body::from_stream(stream::iter(events)))
.unwrap()
}
}),
);
let base_url = start_server(app).await;
let client = test_client(&base_url);
let (mut event_stream, handle) = client.connect_stream("tok", 30).await.unwrap();
use futures::StreamExt;
let event = event_stream.next().await.expect("should get event");
assert_eq!(
event.text(),
"hello 🦀 world",
"emoji should not be corrupted"
);
assert!(sent.load(Ordering::SeqCst));
handle.abort();
}
// ── Channel event field validation ────────────────────────────────────── // ── Channel event field validation ──────────────────────────────────────
#[test] #[test]
fn test_channel_event_missing_fields_detected() { fn test_channel_event_missing_fields_detected() {
use ironclaw::channels::relay::client::ChannelEvent;
// Event with empty sender_id should be detectable // Event with empty sender_id should be detectable
let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "", "content": "test"}"#; let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "", "content": "test"}"#;
let event: ChannelEvent = serde_json::from_str(json).unwrap(); let event: ChannelEvent = serde_json::from_str(json).unwrap();
+1 -1
View File
@@ -308,7 +308,7 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
// Create workspace with mock embeddings (1536 dimensions to match OpenAI) // Create workspace with mock embeddings (1536 dimensions to match OpenAI)
let embeddings = Arc::new(MockEmbeddings::new(1536)); let embeddings = Arc::new(MockEmbeddings::new(1536));
let workspace = Workspace::new(user_id, pool.clone()).with_embeddings(embeddings); let workspace = Workspace::new(user_id, pool.clone()).with_embeddings_uncached(embeddings);
// Write documents // Write documents
workspace workspace