Compare commits

..
Author SHA1 Message Date
ZakiandClaude Opus 4.6 9cb64dd8a7 fix: clarify test comment and use exact assertions
Address Gemini review feedback:
- Fix misleading comment: \u{00e9} is precomposed e-acute, not combining accent
- Replace weak assertions (ends_with/is_empty) with exact assert_eq!

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 16:56:56 -07:00
ZakiandClaude Opus 4.6 6198c98673 fix: prevent UTF-8 panics in byte-index string truncation
Replace unsafe `&s[..n]` patterns with `floor_char_boundary(s, n)` at 3
production code sites where the truncation index could land mid-multibyte
character, panicking on non-ASCII input:

- src/llm/nearai_chat.rs: API response truncation in error message
- src/cli/memory.rs: memory content display truncation
- src/cli/config.rs: config value display truncation

All 3 sites operate on external or user-supplied strings that may contain
non-ASCII characters. The existing `crate::util::floor_char_boundary`
utility (used at 18 other call sites) walks back to the nearest char
boundary, preventing the panic.

Adds regression test with multi-byte characters (combining accents and
4-byte emoji) for truncate_content.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 16:34:14 -07:00
dd0a0e10ab fix(routines): recover delete name after failed update fallback (#1108)
Co-authored-by: [email protected] <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 16:20:01 -07:00
1d5777824c fix(mcp): handle 202 Accepted and wire session manager for Streamable HTTP (#1437)
* fix(mcp): handle 202 Accepted for Streamable HTTP notifications

The MCP Streamable HTTP spec requires servers to respond with
202 Accepted (empty body) for JSON-RPC notifications like
`notifications/initialized`. The HTTP transport tried to parse
this empty body as JSON, which failed and broke the session
handshake — subsequent requests like `tools/list` were rejected
because the server considered the session uninitialized.

Add an early return for 202 responses that produces an empty
McpResponse without attempting body parsing.

Fixes #1436

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(mcp): wire session manager into transport for non-OAuth HTTP clients

The factory used McpClient::new_with_config().with_session_manager()
which only set the session manager on the client, not on the
HttpMcpTransport. The transport never captured Mcp-Session-Id from
responses, so subsequent requests lacked the header and the server
rejected them as uninitialized.

Fix by constructing the HttpMcpTransport with the session manager
before wrapping it in Arc, matching the pattern already used by
new_authenticated().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(mcp): deduplicate factory HTTP path, gate dead-code methods as test-only

- Collapse the two identical non-OAuth HTTP branches in
  `create_client_from_config()` into one (early-return for the
  authenticated path, fall through for the common case).
- Gate `McpClient::new_with_config()` and `McpClient::with_session_manager()`
  as `#[cfg(test)]` — the factory was their only production caller and no
  longer uses them. Both methods silently skip wiring the session manager
  into the transport, which was the root cause of #1436.
- Add doc warnings on both methods explaining the footgun.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-26 14:47:31 -07:00
adf4e25c8f fix(extensions): channel-relay auth dead-end, observability, and URL override (#1681)
* fix(extensions): channel-relay auth dead-end, add observability and relay URL override

Fix a bug where clicking Activate on the Slack relay extension produces
a dead-end "Authentication required" error with no OAuth URL. The root
cause: `auth_channel_relay()` used `is_relay_channel()` to check auth
status, but that function returns true as soon as the extension is
*installed* (in-memory set), before OAuth completes. This short-circuits
the OAuth flow so the authorization URL is never offered.

Changes:

1. **Bug fix** — `auth_channel_relay()` now uses `has_stored_team_id()`
   which only checks the persistent settings store for an actual team_id.
   The extension list `authenticated` field uses the same check so the UI
   accurately reflects OAuth completion status.

2. **Observability** — Added debug/warn/info tracing to all channel-relay
   code paths that were previously silent on failure:
   - `activate_channel_relay`: team_id retrieval, relay config, signing
     secret fetch, hot_add, cache operations
   - `auth_channel_relay`: auth check, OAuth initiation, nonce storage
   - `extensions_activate_handler`: request entry, auth fallback flow
   - `slack_relay_oauth_callback_handler`: team_id persistence (was
     silently ignored with `let _`)
   - `RelayClient`: initiate_oauth, get_signing_secret, proxy_provider
     all log URL, status, and errors
   - `has_stored_team_id`: store read success/failure

3. **Per-extension relay URL override** — Users can now override the
   CHANNEL_RELAY_URL via Settings > Extensions > Reconfigure. Stored
   under `extensions.{name}.relay_url` in settings. Both auth and
   activate read this override before falling back to the env default.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review feedback — clear relay_url override and improve log message

1. Allow clearing the relay_url override: when an optional setup field
   with a setting_path is submitted empty, delete the stored setting so
   the system reverts to the env/default value. Previously empty values
   were silently skipped, making it impossible to undo an override from
   the UI.

2. Improve the OAuth callback team_id persistence error log to be
   self-contained without referencing implementation details.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: collapse nested if per clippy::collapsible_if

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review feedback — security, scope consistency, and error handling

1. OAuth callback team_id persistence is now fatal: if set_setting fails,
   the callback returns an error instead of proceeding to activate (which
   would re-read from the store and fail anyway).

2. effective_relay_url uses owner scope (self.user_id) for reads, matching
   configure() which writes under the same scope. Prevents multi-user
   mismatch where an override saved via Reconfigure was invisible during
   auth/activation.

3. has_stored_team_id uses owner scope for the same reason — the OAuth
   callback stores team_id under state.owner_id (= self.user_id).

4. Security: effective_relay_url validates the override URL — only
   http/https without embedded credentials (userinfo) is accepted. This
   prevents API-key exfiltration if a user points relay_url at an
   attacker-controlled host. Logs only host portion, not full URL.

5. Fixed effective_relay_url docstring to match behavior (returns Option,
   callers handle the fallback).

6. get_setup_schema for ChannelRelay now logs a warning on settings store
   errors instead of silently returning None.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 13:49:05 -07:00
32 changed files with 883 additions and 1058 deletions
+2 -439
View File
@@ -24,8 +24,6 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::error::RoutineError;
@@ -54,55 +52,6 @@ pub struct Routine {
pub updated_at: DateTime<Utc>,
}
const ROUTINE_VERIFICATION_STATE_KEY: &str = "_verification";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RoutineVerificationRecord {
current_fingerprint: String,
#[serde(default)]
verified_fingerprint: Option<String>,
#[serde(default)]
last_verified_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineVerificationStatus {
Verified,
Unverified,
}
impl RoutineVerificationStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineVerificationStatus::Verified => "verified",
RoutineVerificationStatus::Unverified => "unverified",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineDisplayStatus {
Disabled,
Running,
Unverified,
Failing,
Attention,
Active,
}
impl RoutineDisplayStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineDisplayStatus::Disabled => "disabled",
RoutineDisplayStatus::Running => "running",
RoutineDisplayStatus::Unverified => "unverified",
RoutineDisplayStatus::Failing => "failing",
RoutineDisplayStatus::Attention => "attention",
RoutineDisplayStatus::Active => "active",
}
}
}
/// When a routine should fire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -568,155 +517,6 @@ pub fn content_hash(content: &str) -> u64 {
hasher.finish()
}
fn routine_state_as_object(state: &Value) -> Map<String, Value> {
state.as_object().cloned().unwrap_or_default()
}
fn routine_verification_record(state: &Value) -> Option<RoutineVerificationRecord> {
state
.as_object()
.and_then(|obj| obj.get(ROUTINE_VERIFICATION_STATE_KEY))
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
}
fn write_routine_verification_record(
state: &Value,
record: RoutineVerificationRecord,
) -> serde_json::Value {
let mut obj = routine_state_as_object(state);
if let Ok(value) = serde_json::to_value(record) {
obj.insert(ROUTINE_VERIFICATION_STATE_KEY.to_string(), value);
}
Value::Object(obj)
}
fn canonicalize_json_value(value: Value) -> Value {
match value {
Value::Array(items) => {
Value::Array(items.into_iter().map(canonicalize_json_value).collect())
}
Value::Object(obj) => {
let mut keys: Vec<String> = obj.keys().cloned().collect();
keys.sort();
let mut canonical = Map::new();
for key in keys {
if let Some(value) = obj.get(&key) {
canonical.insert(key, canonicalize_json_value(value.clone()));
}
}
Value::Object(canonical)
}
other => other,
}
}
pub fn routine_verification_fingerprint(routine: &Routine) -> String {
let canonical = canonicalize_json_value(serde_json::json!({
"trigger_type": routine.trigger.type_tag(),
"trigger": routine.trigger.to_config_json(),
"action_type": routine.action.type_tag(),
"action": routine.action.to_config_json(),
"guardrails": {
"cooldown_secs": routine.guardrails.cooldown.as_secs(),
"max_concurrent": routine.guardrails.max_concurrent,
"dedup_window_secs": routine.guardrails.dedup_window.map(|d| d.as_secs()),
},
}))
.to_string();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
}
pub fn reset_routine_verification_state(
state: &Value,
current_fingerprint: String,
) -> serde_json::Value {
let mut record = routine_verification_record(state).unwrap_or(RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: None,
last_verified_at: None,
});
record.current_fingerprint = current_fingerprint;
write_routine_verification_record(state, record)
}
pub fn apply_routine_verification_result(
state: &Value,
current_fingerprint: String,
status: RunStatus,
now: DateTime<Utc>,
) -> serde_json::Value {
if let Some(mut record) = routine_verification_record(state) {
record.current_fingerprint = current_fingerprint.clone();
if status == RunStatus::Ok {
record.verified_fingerprint = Some(current_fingerprint);
record.last_verified_at = Some(now);
}
write_routine_verification_record(state, record)
} else if status == RunStatus::Ok {
write_routine_verification_record(
state,
RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: Some(current_fingerprint),
last_verified_at: Some(now),
},
)
} else {
state.clone()
}
}
pub fn routine_verification_status(routine: &Routine) -> RoutineVerificationStatus {
let fingerprint = routine_verification_fingerprint(routine);
let verified =
routine_verification_record(&routine.state).map_or(routine.run_count > 0, |record| {
record.current_fingerprint == fingerprint
&& record.verified_fingerprint.as_deref() == Some(fingerprint.as_str())
});
if verified {
RoutineVerificationStatus::Verified
} else {
RoutineVerificationStatus::Unverified
}
}
pub fn routine_display_status(
routine: &Routine,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
routine_display_status_for_verification(
routine,
routine_verification_status(routine),
last_run_status,
)
}
pub fn routine_display_status_for_verification(
routine: &Routine,
verification_status: RoutineVerificationStatus,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
if !routine.enabled {
return RoutineDisplayStatus::Disabled;
}
if last_run_status == Some(RunStatus::Running) {
return RoutineDisplayStatus::Running;
}
if verification_status == RoutineVerificationStatus::Unverified {
return RoutineDisplayStatus::Unverified;
}
if routine.consecutive_failures > 0 {
return RoutineDisplayStatus::Failing;
}
if last_run_status == Some(RunStatus::Attention) {
return RoutineDisplayStatus::Attention;
}
RoutineDisplayStatus::Active
}
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
///
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
@@ -925,14 +725,9 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use crate::agent::routine::{
MAX_TOOL_ROUNDS_LIMIT, NotifyConfig, Routine, RoutineAction, RoutineGuardrails,
RoutineVerificationStatus, RunStatus, Trigger, apply_routine_verification_result,
content_hash, describe_cron, next_cron_fire, normalize_cron_expression,
reset_routine_verification_state, routine_verification_fingerprint,
routine_verification_status,
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
describe_cron, next_cron_fire, normalize_cron_expression,
};
use chrono::Utc;
use uuid::Uuid;
#[test]
fn test_trigger_roundtrip() {
@@ -1066,69 +861,6 @@ mod tests {
assert_ne!(h1, h3);
}
#[test]
fn test_verification_fingerprint_is_digest_not_prompt_content() {
let routine = Routine {
id: Uuid::new_v4(),
name: "hashed".to_string(),
description: "hash test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "super-secret-routine-prompt".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let fingerprint = routine_verification_fingerprint(&routine);
assert_eq!(fingerprint.len(), 64);
assert!(!fingerprint.contains("super-secret-routine-prompt"));
}
#[test]
fn test_system_event_fingerprint_is_stable_when_filter_insertion_order_differs() {
let mut first_filters = std::collections::HashMap::new();
first_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
first_filters.insert("action".to_string(), "opened".to_string());
let mut second_filters = std::collections::HashMap::new();
second_filters.insert("action".to_string(), "opened".to_string());
second_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
let mut first = make_verification_test_routine();
first.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: first_filters,
};
let mut second = make_verification_test_routine();
second.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: second_filters,
};
assert_eq!(
routine_verification_fingerprint(&first),
routine_verification_fingerprint(&second)
);
}
#[test]
fn test_next_cron_fire_valid() {
// Every minute should always have a next fire
@@ -1385,173 +1117,4 @@ mod tests {
_ => panic!("expected Lightweight"),
}
}
fn make_verification_test_routine() -> Routine {
Routine {
id: Uuid::new_v4(),
name: "verify-me".to_string(),
description: "verification test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check routine output".to_string(),
context_paths: Vec::new(),
max_tokens: 1024,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_reset_verification_state_marks_new_routine_unverified() {
let mut routine = make_verification_test_routine();
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_successful_run_verifies_current_fingerprint() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_behavior_change_resets_prior_verification() {
let mut routine = make_verification_test_routine();
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
if let RoutineAction::Lightweight { prompt, .. } = &mut routine.action {
*prompt = "Updated prompt".to_string();
}
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_failed_unverified_run_stays_unverified() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_schedule_change_resets_verification() {
let mut routine = make_verification_test_routine();
routine.trigger = Trigger::Cron {
schedule: "0 0 9 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
routine.trigger = Trigger::Cron {
schedule: "0 0 10 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_legacy_routine_with_runs_is_treated_as_verified_without_metadata() {
let mut routine = make_verification_test_routine();
routine.run_count = 3;
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_failed_legacy_run_preserves_implicit_verification() {
let mut routine = make_verification_test_routine();
routine.run_count = 2;
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
}
+4 -18
View File
@@ -23,8 +23,7 @@ use uuid::Uuid;
use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
@@ -622,7 +621,7 @@ impl RoutineEngine {
);
// Load the routine to update consecutive_failures and send notification
let mut routine = match self.store.get_routine(run.routine_id).await {
let routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
@@ -650,12 +649,6 @@ impl RoutineEngine {
};
let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
@@ -1092,7 +1085,7 @@ struct EngineContext {
}
/// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) {
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed);
@@ -1150,15 +1143,8 @@ async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineR
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
}
let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
// Update routine runtime state
let now = Utc::now();
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
+61 -6
View File
@@ -122,18 +122,32 @@ impl RelayClient {
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let url = format!("{}/oauth/slack/auth", self.base_url);
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::initiate_oauth: network request failed"
);
RelayError::Network(e.to_string())
})?;
tracing::debug!(
relay_url = %url,
status = %resp.status(),
"RelayClient::initiate_oauth: received response"
);
let status = resp.status();
if status.is_redirection() {
@@ -224,20 +238,39 @@ impl RelayClient {
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
tracing::debug!(
relay_url = %url,
provider = %provider,
method = %method,
"RelayClient::proxy_provider: sending request"
);
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.post(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::proxy_provider: network request failed"
);
RelayError::Network(e.to_string())
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
"RelayClient::proxy_provider: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
@@ -255,23 +288,45 @@ impl RelayClient {
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let url = format!("{}/relay/signing-secret", self.base_url);
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: fetching signing secret"
);
let resp = self
.http
.get(format!("{}/relay/signing-secret", self.base_url))
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::get_signing_secret: network request failed"
);
RelayError::Network(e.to_string())
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
body = %body,
"RelayClient::get_signing_secret: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
});
}
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: received successful response"
);
let body: serde_json::Value = resp
.json()
+1 -1
View File
@@ -84,7 +84,7 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/routines` | List routines |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/failing/runs_today) |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) |
| GET | `/api/routines/{id}` | Routine detail with recent run history |
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
+9 -52
View File
@@ -10,10 +10,7 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;
use crate::agent::routine::{
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
routine_display_status_for_verification, routine_verification_status,
};
use crate::agent::routine::{Trigger, next_cron_fire};
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
@@ -33,18 +30,7 @@ pub async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines
.iter()
.map(|routine| {
RoutineInfo::from_routine(routine, last_run_statuses.get(&routine.id).copied())
})
.collect();
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
@@ -63,39 +49,13 @@ pub async fn routines_summary_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = routines.len() as u64;
let mut enabled = 0u64;
let mut disabled = 0u64;
let mut unverified = 0u64;
let mut failing = 0u64;
for routine in &routines {
let verification_status = routine_verification_status(routine);
if routine.enabled {
enabled += 1;
} else {
disabled += 1;
}
if verification_status == RoutineVerificationStatus::Unverified {
unverified += 1;
}
if routine_display_status_for_verification(
routine,
verification_status,
last_run_statuses.get(&routine.id).copied(),
) == RoutineDisplayStatus::Failing
{
failing += 1;
}
}
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
let disabled = total - enabled;
let failing = routines
.iter()
.filter(|r| r.consecutive_failures > 0)
.count() as u64;
let today_start = chrono::Utc::now()
.date_naive()
@@ -114,7 +74,6 @@ pub async fn routines_summary_handler(
total,
enabled,
disabled,
unverified,
failing,
runs_today,
}))
@@ -161,7 +120,7 @@ pub async fn routines_detail_handler(
job_id: run.job_id,
})
.collect();
let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status));
let routine_info = RoutineInfo::from_routine(&routine);
Ok(Json(RoutineDetailResponse {
id: routine.id,
@@ -179,8 +138,6 @@ pub async fn routines_detail_handler(
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: routine.run_count,
consecutive_failures: routine.consecutive_failures,
status: routine_info.status.clone(),
verification_status: routine_info.verification_status.clone(),
created_at: routine.created_at.to_rfc3339(),
recent_runs,
}))
+50 -3
View File
@@ -1177,11 +1177,31 @@ async fn slack_relay_oauth_callback_handler(
// Store team_id in settings
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
tracing::info!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
team_id_key = %team_id_key,
"relay OAuth callback: storing team_id in settings"
);
store
.set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id))
.await;
.await
.map_err(|e| {
tracing::error!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
error = %e,
"relay OAuth callback: failed to persist team_id to settings store"
);
format!("Failed to persist relay team_id: {e}")
})?;
// Activate the relay channel
tracing::info!(
relay = DEFAULT_RELAY_NAME,
owner_id = %state.owner_id,
"relay OAuth callback: activating relay channel"
);
ext_mgr
.activate_stored_relay(DEFAULT_RELAY_NAME, &state.owner_id)
.await
@@ -2181,6 +2201,11 @@ async fn extensions_activate_handler(
AuthenticatedUser(user): AuthenticatedUser,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
tracing::debug!(
extension = %name,
user_id = %user.user_id,
"extensions_activate_handler: received activate request"
);
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
@@ -2188,6 +2213,10 @@ async fn extensions_activate_handler(
match ext_mgr.activate(&name, &user.user_id).await {
Ok(result) => {
tracing::info!(
extension = %name,
"extensions_activate_handler: activation succeeded"
);
// Activation loaded the WASM module. Check if the tool needs
// OAuth scope expansion (e.g., adding google-docs when gmail
// already has a token but missing the documents scope).
@@ -2206,6 +2235,13 @@ async fn extensions_activate_handler(
crate::extensions::ExtensionError::AuthRequired
);
tracing::debug!(
extension = %name,
error = %activate_err,
needs_auth = needs_auth,
"extensions_activate_handler: activation failed, attempting auth fallback"
);
if !needs_auth {
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
}
@@ -2213,10 +2249,21 @@ async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, &user.user_id).await {
Ok(auth_result) if auth_result.is_authenticated() => {
tracing::debug!(
extension = %name,
"extensions_activate_handler: auth reports authenticated, retrying activate"
);
// Auth succeeded, retry activation.
match ext_mgr.activate(&name, &user.user_id).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"extensions_activate_handler: retry after auth still failed"
);
Ok(Json(ActionResponse::fail(e.to_string())))
}
}
}
Ok(auth_result) => {
+7 -27
View File
@@ -4141,7 +4141,6 @@ function renderRoutinesSummary(s) {
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.unverified'), s.unverified, 'pending')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
}
@@ -4160,8 +4159,6 @@ function renderRoutinesList(routines) {
tbody.innerHTML = routines.map((r) => {
const statusClass = r.status === 'active' ? 'completed'
: r.status === 'failing' ? 'failed'
: r.status === 'attention' ? 'stuck'
: r.status === 'running' ? 'in_progress'
: 'pending';
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
@@ -4169,9 +4166,6 @@ function renderRoutinesList(routines) {
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
? ' title="' + escapeHtml(r.trigger_raw) + '"'
: '';
const runLabel = (r.verification_status === 'unverified' || r.status === 'unverified')
? 'Verify now'
: 'Run';
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
+ '<td>' + escapeHtml(r.name) + '</td>'
@@ -4183,7 +4177,7 @@ function renderRoutinesList(routines) {
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
+ '<td>'
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
+ '</td>'
+ '</tr>';
@@ -4212,12 +4206,12 @@ function renderRoutineDetail(routine) {
const detail = document.getElementById('routine-detail');
detail.style.display = 'block';
const statusClass = routine.status === 'active' ? 'completed'
: routine.status === 'failing' ? 'failed'
: routine.status === 'attention' ? 'stuck'
: routine.status === 'running' ? 'in_progress'
: 'pending';
const statusLabel = routine.status || 'active';
const statusClass = !routine.enabled ? 'pending'
: routine.consecutive_failures > 0 ? 'failed'
: 'completed';
const statusLabel = !routine.enabled ? 'disabled'
: routine.consecutive_failures > 0 ? 'failing'
: 'active';
let html = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>'
@@ -4242,20 +4236,6 @@ function renderRoutineDetail(routine) {
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
}
if (routine.verification_status === 'unverified') {
let verificationCopy = 'Created or updated, but not yet verified with a successful run.';
if (routine.recent_runs && routine.recent_runs.length > 0) {
const latestRun = routine.recent_runs[0];
if (latestRun.status === 'failed') {
verificationCopy = 'The latest verification attempt failed. Review the run details and verify again after fixing it.';
} else if (latestRun.status === 'attention') {
verificationCopy = 'The latest verification attempt needs attention. Review the run details and verify again when ready.';
}
}
html += '<div class="job-description"><h3>Verification</h3>'
+ '<div class="job-description-body">' + escapeHtml(verificationCopy) + '</div></div>';
}
// Trigger config
if (routine.trigger_type === 'cron') {
const summary = routine.trigger_summary || 'cron';
-1
View File
@@ -207,7 +207,6 @@ I18n.register('en', {
'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled',
'routines.summary.unverified': 'Unverified',
'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today',
-1
View File
@@ -207,7 +207,6 @@ I18n.register('zh-CN', {
'routines.summary.total': '总计',
'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用',
'routines.summary.unverified': '未验证',
'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行',
+8 -143
View File
@@ -662,15 +662,11 @@ pub struct RoutineInfo {
pub run_count: u64,
pub consecutive_failures: u32,
pub status: String,
pub verification_status: String,
}
impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine(
r: &crate::agent::routine::Routine,
last_run_status: Option<crate::agent::routine::RunStatus>,
) -> Self {
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, timezone } => (
"cron".to_string(),
@@ -714,13 +710,13 @@ impl RoutineInfo {
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let verification_status = crate::agent::routine::routine_verification_status(r);
let status = crate::agent::routine::routine_display_status_for_verification(
r,
verification_status,
last_run_status,
)
.as_str();
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
@@ -736,7 +732,6 @@ impl RoutineInfo {
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
verification_status: verification_status.as_str().to_string(),
}
}
}
@@ -751,7 +746,6 @@ pub struct RoutineSummaryResponse {
pub total: u64,
pub enabled: u64,
pub disabled: u64,
pub unverified: u64,
pub failing: u64,
pub runs_today: u64,
}
@@ -773,8 +767,6 @@ pub struct RoutineDetailResponse {
pub next_fire_at: Option<String>,
pub run_count: u64,
pub consecutive_failures: u32,
pub status: String,
pub verification_status: String,
pub created_at: String,
pub recent_runs: Vec<RoutineRunInfo>,
}
@@ -831,7 +823,6 @@ pub struct HealthResponse {
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
// ---- WsClientMessage deserialization tests ----
@@ -1182,130 +1173,4 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("channel").is_none());
}
fn make_routine_for_status_tests() -> crate::agent::routine::Routine {
crate::agent::routine::Routine {
id: Uuid::new_v4(),
name: "status-check".to_string(),
description: "routine status test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: crate::agent::routine::Trigger::Manual,
action: crate::agent::routine::RoutineAction::Lightweight {
prompt: "Check status".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: crate::agent::routine::RoutineGuardrails::default(),
notify: crate::agent::routine::NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_routine_info_marks_new_routine_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "unverified");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_preserves_verified_state_for_description_only_changes() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.description = "Updated description".to_string();
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_surfaces_running_before_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info =
RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Running));
assert_eq!(info.status, "running");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_keeps_verified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_treats_legacy_run_history_as_verified() {
let mut routine = make_routine_for_status_tests();
routine.run_count = 2;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_keeps_unverified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "unverified");
}
}
+2 -1
View File
@@ -127,7 +127,8 @@ async fn list_settings(
}
let display_value = if value.len() > 60 {
format!("{}...", &value[..57])
let end = crate::util::floor_char_boundary(&value, 57);
format!("{}...", &value[..end])
} else {
value
};
+15 -1
View File
@@ -256,7 +256,8 @@ fn truncate_content(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len])
let end = crate::util::floor_char_boundary(s, max_len);
format!("{}...", &s[..end])
}
}
@@ -292,4 +293,17 @@ mod tests {
assert_eq!(truncate_content("hello", 10), "hello");
assert_eq!(truncate_content("hello world", 5), "hello...");
}
#[test]
fn test_truncate_content_multibyte_does_not_panic() {
// \u{00e9} is precomposed 'é' (2 bytes in UTF-8)
let s = "caf\u{00e9} au lait"; // "café au lait", é starts at byte 3
let result = truncate_content(s, 4); // byte 4 is inside 2-byte é
assert_eq!(result, "caf...");
// 4-byte emoji: slicing mid-emoji must not panic
let emoji = "Hi \u{1F600} there"; // 😀 is 4 bytes, starts at byte 3
let result = truncate_content(emoji, 4); // byte 4 is inside 😀
assert_eq!(result, "Hi ...");
}
}
+3
View File
@@ -192,6 +192,9 @@ pub struct JobContext {
/// but subsequent tools (e.g., `json`) may need the full output. This
/// stash stores the complete, unsanitized output so tools can reference
/// previous results by ID via `$tool_call_id` parameter syntax.
///
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
/// as `__routine_last_name` for fallback recovery in routine tool chains.
#[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
+20 -114
View File
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use libsql::{params, params_from_iter};
use libsql::params;
use uuid::Uuid;
use super::{
@@ -471,33 +471,25 @@ impl RoutineStore for LibSqlBackend {
}
let conn = self.connect().await?;
let requested_rows = (1..=routine_ids.len())
.map(|i| format!("(?{i})"))
.collect::<Vec<_>>()
.join(", ");
let requested_ids = routine_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>();
let sql = format!(
"WITH requested(routine_id) AS (VALUES {requested_rows})
SELECT r1.routine_id, r1.status
FROM routine_runs r1
JOIN (
SELECT rr.routine_id, MAX(rr.started_at) AS max_started_at
FROM routine_runs rr
JOIN requested req ON req.routine_id = rr.routine_id
GROUP BY rr.routine_id
) latest
ON latest.routine_id = r1.routine_id
AND latest.max_started_at = r1.started_at"
);
// SQLite doesn't support ANY($1), so we query all latest runs and filter in memory.
// Uses a subquery to pick only the most recent run per routine.
let mut rows = conn
.query(&sql, params_from_iter(requested_ids))
.query(
"SELECT routine_id, status FROM routine_runs r1
WHERE started_at = (
SELECT MAX(started_at) FROM routine_runs r2
WHERE r2.routine_id = r1.routine_id
)
GROUP BY routine_id",
params![],
)
.await
.map_err(|e| {
DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
})?;
let routine_id_set: HashSet<Uuid> = routine_ids.iter().copied().collect();
let mut statuses = HashMap::new();
while let Some(row) = rows
@@ -509,9 +501,11 @@ impl RoutineStore for LibSqlBackend {
let id = Uuid::parse_str(&id_str)
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
let status_str: String = get_text(&row, 1);
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
statuses.insert(id, status);
if routine_id_set.contains(&id) {
let status_str: String = get_text(&row, 1);
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
statuses.insert(id, status);
}
}
}
@@ -600,91 +594,3 @@ impl RoutineStore for LibSqlBackend {
Ok(runs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, Trigger,
};
use crate::db::{Database, RoutineStore};
fn test_routine(user_id: &str, name: &str) -> Routine {
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: "test routine".to_string(),
user_id: user_id.to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "test".to_string(),
context_paths: Vec::new(),
max_tokens: 128,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn test_run(routine_id: Uuid, status: RunStatus, started_at: DateTime<Utc>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at,
completed_at: None,
status,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: started_at,
}
}
#[tokio::test]
async fn batch_get_last_run_status_is_scoped_to_requested_routines() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("routine-status.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let requested = test_routine("user-1", "requested");
let other = test_routine("user-1", "other");
backend.create_routine(&requested).await.unwrap();
backend.create_routine(&other).await.unwrap();
let now = Utc::now();
backend
.create_routine_run(&test_run(requested.id, RunStatus::Ok, now))
.await
.unwrap();
backend
.create_routine_run(&test_run(
other.id,
RunStatus::Failed,
now + chrono::Duration::seconds(1),
))
.await
.unwrap();
let statuses = backend
.batch_get_last_run_status(&[requested.id])
.await
.unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses.get(&requested.id), Some(&RunStatus::Ok));
assert!(!statuses.contains_key(&other.id));
}
}
+389 -30
View File
@@ -659,6 +659,66 @@ impl ExtensionManager {
})
}
/// Resolve the relay URL override for an extension from settings.
///
/// Returns `Some(url)` if a non-empty per-extension `relay_url` override is
/// set for the given extension; otherwise returns `None` and callers should
/// fall back to the env-level `RelayConfig`.
///
/// Uses `self.user_id` (owner scope) for consistency with `configure()`,
/// which also writes setting_path fields under the owner scope.
///
/// The override is validated: only `http` / `https` schemes are accepted
/// and the URL must not contain userinfo (embedded credentials). This
/// prevents a malicious override from exfiltrating the instance-wide relay
/// API key to an attacker-controlled host.
async fn effective_relay_url(&self, name: &str) -> Option<String> {
if let Some(ref store) = self.store {
let key = format!("extensions.{name}.relay_url");
if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await {
let url = v
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(ref u) = url {
// Validate the override to prevent API-key exfiltration:
// only allow http(s) with no embedded credentials.
match url::Url::parse(u) {
Ok(parsed)
if (parsed.scheme() == "http" || parsed.scheme() == "https")
&& parsed.username().is_empty()
&& parsed.password().is_none() =>
{
tracing::debug!(
extension = %name,
relay_url_host = %parsed.host_str().unwrap_or("unknown"),
"effective_relay_url: using per-extension override from settings"
);
return url;
}
Ok(parsed) => {
tracing::warn!(
extension = %name,
scheme = %parsed.scheme(),
has_userinfo = !parsed.username().is_empty() || parsed.password().is_some(),
"effective_relay_url: rejecting override — \
only http/https without embedded credentials is allowed"
);
}
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"effective_relay_url: rejecting override — invalid URL"
);
}
}
}
}
}
None
}
/// Get the shared relay event sender for the webhook endpoint.
pub fn relay_event_tx(
&self,
@@ -892,6 +952,46 @@ impl ExtensionManager {
false
}
/// Check whether a stored `team_id` setting exists for the given relay extension.
///
/// Unlike [`is_relay_channel`], this does **not** consult the in-memory
/// `installed_relay_extensions` set — it only looks at the persistent settings
/// store. This distinction matters for `auth_channel_relay`: an extension can
/// be *installed* (present in the in-memory set) but not yet *authenticated*
/// (no OAuth completed, no team_id stored).
async fn has_stored_team_id(&self, name: &str, _user_id: &str) -> bool {
if let Some(ref store) = self.store {
let key = format!("relay:{}:team_id", name);
// Use owner scope (self.user_id) for consistency: the OAuth callback
// stores team_id under state.owner_id which maps to self.user_id.
match store.get_setting(&self.user_id, &key).await {
Ok(Some(v)) => {
let has_id = v.as_str().is_some_and(|s| !s.is_empty());
tracing::debug!(
extension = %name,
has_team_id = has_id,
"has_stored_team_id: checked store"
);
return has_id;
}
Ok(None) => {
tracing::debug!(
extension = %name,
"has_stored_team_id: no team_id setting found"
);
}
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"has_stored_team_id: failed to read from settings store"
);
}
}
}
false
}
/// Restore persisted relay channels after startup.
///
/// Loads the persisted active channel list, filters to relay types (those with
@@ -1418,7 +1518,7 @@ impl ExtensionManager {
let errors = self.activation_errors.read().await;
for name in installed.iter() {
let active = active_names.contains(name);
let authenticated = self.is_relay_channel(name, user_id).await;
let authenticated = self.has_stored_team_id(name, user_id).await;
let activation_error = errors.get(name).cloned();
let registry_entry = self
.registry
@@ -4191,20 +4291,69 @@ impl ExtensionManager {
name: &str,
user_id: &str,
) -> Result<AuthResult, ExtensionError> {
// Check if already authenticated (team_id setting exists)
if self.is_relay_channel(name, user_id).await {
tracing::debug!(
extension = %name,
user_id = %user_id,
"auth_channel_relay: starting"
);
// Check if already authenticated by looking for a stored team_id.
// We intentionally skip the `installed_relay_extensions` in-memory set
// here because that set only tracks *installed* extensions — an extension
// can be installed (via registry) but not yet authenticated (no OAuth
// completed). Checking just `is_relay_channel()` would short-circuit
// to "authenticated" even when no team_id exists, preventing the OAuth
// flow from being offered to the user.
if self.has_stored_team_id(name, user_id).await {
tracing::debug!(
extension = %name,
"auth_channel_relay: already authenticated (team_id in store)"
);
return Ok(AuthResult::authenticated(name, ExtensionKind::ChannelRelay));
}
tracing::debug!(
extension = %name,
"auth_channel_relay: no stored team_id, initiating OAuth"
);
// Use relay config captured at startup
let relay_config = self.relay_config()?;
let relay_config = self.relay_config().map_err(|e| {
tracing::warn!(
extension = %name,
error = %e,
"auth_channel_relay: relay config not available — \
CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set"
);
e
})?;
// Allow per-extension URL override from settings
let effective_url = self
.effective_relay_url(name)
.await
.unwrap_or_else(|| relay_config.url.clone());
tracing::debug!(
extension = %name,
relay_url = %effective_url,
"auth_channel_relay: creating relay client for OAuth"
);
let client = crate::channels::relay::RelayClient::new(
relay_config.url.clone(),
effective_url.clone(),
relay_config.api_key.clone(),
relay_config.request_timeout_secs,
)
.map_err(|e| ExtensionError::Config(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
extension = %name,
relay_url = %effective_url,
error = %e,
"auth_channel_relay: failed to create relay HTTP client"
);
ExtensionError::Config(e.to_string())
})?;
// Generate CSRF nonce — IronClaw validates this on the callback to ensure
// the OAuth completion is legitimate. Channel-relay embeds it in the signed
@@ -4216,18 +4365,44 @@ impl ExtensionManager {
self.secrets
.create(user_id, CreateSecretParams::new(&state_key, &state_nonce))
.await
.map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?;
.map_err(|e| {
tracing::warn!(
extension = %name,
error = %e,
"auth_channel_relay: failed to store OAuth state nonce"
);
ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}"))
})?;
// Channel-relay derives all URLs from trusted instance_url in chat-api.
// We only pass the nonce for CSRF validation on the callback.
tracing::debug!(
extension = %name,
relay_url = %effective_url,
"auth_channel_relay: calling initiate_oauth on channel-relay"
);
match client.initiate_oauth(Some(&state_nonce)).await {
Ok(auth_url) => Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::ChannelRelay,
auth_url,
"redirect".to_string(),
)),
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),
Ok(auth_url) => {
tracing::info!(
extension = %name,
"auth_channel_relay: OAuth URL obtained, awaiting user authorization"
);
Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::ChannelRelay,
auth_url,
"redirect".to_string(),
))
}
Err(e) => {
tracing::warn!(
extension = %name,
relay_url = %effective_url,
error = %e,
"auth_channel_relay: initiate_oauth call to channel-relay failed"
);
Err(ExtensionError::AuthFailed(e.to_string()))
}
}
}
@@ -4237,40 +4412,112 @@ impl ExtensionManager {
name: &str,
user_id: &str,
) -> Result<ActivateResult, ExtensionError> {
tracing::debug!(
extension = %name,
user_id = %user_id,
"activate_channel_relay: starting"
);
let team_id_key = format!("relay:{}:team_id", name);
// Get team_id from settings (stored by the OAuth callback)
let team_id = if let Some(ref store) = self.store {
store
.get_setting(user_id, &team_id_key)
.await
.ok()
.flatten()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default()
match store.get_setting(user_id, &team_id_key).await {
Ok(Some(v)) => {
let id = v.as_str().map(|s| s.to_string()).unwrap_or_default();
tracing::debug!(
extension = %name,
team_id_empty = id.is_empty(),
"activate_channel_relay: loaded team_id from store"
);
id
}
Ok(None) => {
tracing::debug!(
extension = %name,
setting_key = %team_id_key,
"activate_channel_relay: no team_id in settings store"
);
String::new()
}
Err(e) => {
tracing::warn!(
extension = %name,
error = %e,
"activate_channel_relay: failed to read team_id from settings store"
);
String::new()
}
}
} else {
tracing::debug!(
extension = %name,
"activate_channel_relay: no settings store available"
);
String::new()
};
if team_id.is_empty() {
tracing::debug!(
extension = %name,
"activate_channel_relay: team_id is empty, returning AuthRequired"
);
return Err(ExtensionError::AuthRequired);
}
// Use relay config captured at startup
let relay_config = self.relay_config()?;
let relay_config = self.relay_config().map_err(|e| {
tracing::warn!(
extension = %name,
error = %e,
"activate_channel_relay: relay config not available"
);
e
})?;
// Allow per-extension URL override from settings
let effective_url = self
.effective_relay_url(name)
.await
.unwrap_or_else(|| relay_config.url.clone());
tracing::debug!(
extension = %name,
relay_url = %effective_url,
"activate_channel_relay: relay config loaded"
);
let instance_id = self.relay_instance_id(relay_config, user_id);
let client = crate::channels::relay::RelayClient::new(
relay_config.url.clone(),
effective_url.clone(),
relay_config.api_key.clone(),
relay_config.request_timeout_secs,
)
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
.map_err(|e| {
tracing::warn!(
extension = %name,
relay_url = %effective_url,
error = %e,
"activate_channel_relay: failed to create relay HTTP client"
);
ExtensionError::ActivationFailed(e.to_string())
})?;
// Fetch the per-instance signing secret from channel-relay.
// This must succeed — there is no fallback.
tracing::debug!(
extension = %name,
relay_url = %effective_url,
"activate_channel_relay: fetching signing secret from channel-relay"
);
let signing_secret = client.get_signing_secret(&team_id).await.map_err(|e| {
tracing::warn!(
extension = %name,
relay_url = %effective_url,
error = %e,
"activate_channel_relay: failed to fetch signing secret from channel-relay"
);
ExtensionError::Config(format!("Failed to fetch relay signing secret: {e}"))
})?;
@@ -4289,16 +4536,29 @@ impl ExtensionManager {
// Hot-add to channel manager
let cm_guard = self.relay_channel_manager.read().await;
let channel_mgr = cm_guard.as_ref().ok_or_else(|| {
tracing::warn!(
extension = %name,
"activate_channel_relay: channel manager not initialized"
);
ExtensionError::ActivationFailed("Channel manager not initialized".to_string())
})?;
channel_mgr
.hot_add(Box::new(channel))
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
channel_mgr.hot_add(Box::new(channel)).await.map_err(|e| {
tracing::warn!(
extension = %name,
error = %e,
"activate_channel_relay: hot_add to channel manager failed"
);
ExtensionError::ActivationFailed(e.to_string())
})?;
if let Ok(mut cache) = self.relay_signing_secret_cache.lock() {
*cache = Some(signing_secret);
} else {
tracing::warn!(
extension = %name,
"activate_channel_relay: failed to cache signing secret (mutex poisoned)"
);
}
// Store the event sender so the web gateway's relay webhook endpoint can push events
@@ -4316,6 +4576,12 @@ impl ExtensionManager {
self.broadcast_extension_status(name, "active", Some(&status_msg))
.await;
tracing::info!(
extension = %name,
instance_id = %instance_id,
"activate_channel_relay: relay channel activated successfully"
);
Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::ChannelRelay,
@@ -4595,6 +4861,41 @@ impl ExtensionManager {
}
Ok(ExtensionSetupSchema { secrets, fields })
}
ExtensionKind::ChannelRelay => {
let relay_url_key = format!("extensions.{name}.relay_url");
let current_url = if let Some(ref store) = self.store {
match store.get_setting(&self.user_id, &relay_url_key).await {
Ok(value_opt) => value_opt
.and_then(|v| v.as_str().map(|s| s.to_string()))
.filter(|s| !s.is_empty()),
Err(e) => {
tracing::warn!(
extension = %name,
setting_key = %relay_url_key,
error = %e,
"get_setup_schema: failed to read relay_url from settings"
);
None
}
}
} else {
None
};
let env_url = self.relay_config.as_ref().map(|c| c.url.as_str());
Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: vec![crate::channels::web::types::SetupFieldInfo {
name: "relay_url".to_string(),
prompt: format!(
"Channel-relay service URL (leave empty to use env default{})",
env_url.map(|u| format!(": {u}")).unwrap_or_default()
),
optional: true,
provided: current_url.is_some(),
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
}],
})
}
_ => Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
@@ -4997,7 +5298,17 @@ impl ExtensionManager {
names.insert(server.token_secret_name());
(names, Vec::new())
}
ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), Vec::new()),
ExtensionKind::ChannelRelay => {
let relay_fields = vec![crate::tools::wasm::ToolFieldSetupSchema {
name: "relay_url".to_string(),
prompt: "Channel-relay service URL override".to_string(),
optional: true,
setting_path: Some(format!("extensions.{name}.relay_url")),
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
restart_required: false,
}];
(std::collections::HashSet::new(), relay_fields)
}
};
let allowed_fields: std::collections::HashSet<String> =
@@ -5088,13 +5399,28 @@ impl ExtensionManager {
)));
}
let trimmed = field_value.trim();
let field_def = setup_field_defs.get(field_name);
// Empty value on an optional field with a setting_path: clear the
// stored override so the system reverts to the env/default value.
if trimmed.is_empty() {
if let Some(def) = field_def
&& def.optional
{
stored_fields.remove(field_name);
if let Some(setting_path) = &def.setting_path {
Self::validate_setup_setting_path(name, setting_path)?;
if let Some(store) = self.store.as_ref() {
let _ = store.delete_setting(&self.user_id, setting_path).await;
}
}
}
continue;
}
stored_fields.insert(field_name.clone(), trimmed.to_string());
if let Some(field_def) = setup_field_defs.get(field_name) {
if let Some(field_def) = field_def {
if field_def.restart_required {
restart_required = true;
}
@@ -7058,6 +7384,39 @@ mod tests {
);
}
/// Regression: installed-but-not-authenticated relay must NOT short-circuit
/// `auth_channel_relay()` to "authenticated". Previously, `auth_channel_relay`
/// called `is_relay_channel()` which checked the in-memory
/// `installed_relay_extensions` set; that returned `true` even when no team_id
/// existed in the store, so the OAuth URL was never offered.
#[tokio::test]
async fn test_auth_channel_relay_installed_without_team_id_is_not_authenticated() {
let dir = tempfile::tempdir().expect("temp dir");
let mgr = make_test_manager(None, dir.path().to_path_buf());
// Mark as installed (simulates clicking Install in the UI)
mgr.installed_relay_extensions
.write()
.await
.insert("slack-relay".to_string());
// Without a stored team_id, auth should NOT return authenticated.
// It should fail because relay config is missing (no CHANNEL_RELAY_URL),
// but the key assertion is that it does NOT return Ok(authenticated).
let result = mgr.auth_channel_relay("slack-relay", "test").await;
match result {
Ok(ref auth_result) if auth_result.is_authenticated() => {
panic!(
"auth_channel_relay returned authenticated for installed-but-no-team-id relay; \
expected either an OAuth URL or a config error"
);
}
_ => {
// Config error (no relay URL) or awaiting_authorization — both are correct
}
}
}
#[tokio::test]
async fn test_remove_relay_shuts_down_via_relay_channel_manager() {
// Regression: remove() only checked channel_runtime for shutdown, missing
+1 -1
View File
@@ -451,7 +451,7 @@ impl NearAiChatProvider {
provider: "nearai_chat".to_string(),
reason: format!(
"No model names found in response: {}",
&response_text[..response_text.len().min(300)]
&response_text[..crate::util::floor_char_boundary(&response_text, 300)]
),
})
}
+44 -79
View File
@@ -20,8 +20,7 @@ use uuid::Uuid;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
normalize_cron_expression, reset_routine_verification_state, routine_verification_fingerprint,
routine_verification_status,
normalize_cron_expression,
};
use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext;
@@ -415,29 +414,12 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
"advanced.cooldown_secs defaults to 300.".into(),
"Creating a routine only saves the configuration. It does not prove the routine can execute successfully.".into(),
"After routine_create, tell the user the routine is unverified and offer to test it now unless they asked not to.".into(),
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
],
examples: routine_create_examples(),
}
}
fn verification_result_payload(routine: &Routine, verification_reset: bool) -> Value {
let verification_status = routine_verification_status(routine);
serde_json::json!({
"verification_status": verification_status.as_str(),
"verification_reset": verification_reset,
"verification_hint": if verification_reset {
"The routine configuration changed and should be re-tested before being treated as reliable."
} else if verification_status == crate::agent::routine::RoutineVerificationStatus::Verified {
"The current routine configuration has already been verified with a successful run."
} else {
"The routine has been saved, but it has not been verified yet. Offer to test it now."
}
})
}
fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
let mut schema = serde_json::json!({
"type": "object",
@@ -668,6 +650,23 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
})
}
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
ctx.tool_output_stash
.write()
.await
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
}
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
ctx.tool_output_stash
.read()
.await
.get(ROUTINE_LAST_NAME_STASH_KEY)
.cloned()
}
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
params.get(field).and_then(Value::as_object)
}
@@ -1081,8 +1080,7 @@ impl Tool for RoutineCreateTool {
fn description(&self) -> &str {
"Create a new routine (scheduled or event-driven task). \
Supports cron schedules, event pattern matching, system events, and manual triggers. \
Use this when the user wants something to happen periodically or reactively. \
Creation saves the routine, but does not verify that it will execute successfully."
Use this when the user wants something to happen periodically or reactively."
}
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
@@ -1112,6 +1110,7 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let normalized = parse_routine_create_request(&params)?;
stash_last_routine_name(ctx, &normalized.name).await;
let trigger = build_routine_trigger(&normalized.trigger);
let action =
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
@@ -1127,7 +1126,7 @@ impl Tool for RoutineCreateTool {
None
};
let mut routine = Routine {
let routine = Routine {
id: Uuid::new_v4(),
name: normalized.name.clone(),
description: normalized.description.clone(),
@@ -1153,10 +1152,6 @@ impl Tool for RoutineCreateTool {
created_at: Utc::now(),
updated_at: Utc::now(),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
self.store
.create_routine(&routine)
@@ -1171,14 +1166,12 @@ impl Tool for RoutineCreateTool {
self.engine.refresh_event_cache().await;
}
let verification = verification_result_payload(&routine, false);
let result = serde_json::json!({
"id": routine.id.to_string(),
"name": routine.name.clone(),
"name": routine.name,
"trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "created",
"verification": verification,
});
Ok(ToolOutput::success(result, start.elapsed()))
@@ -1231,24 +1224,10 @@ impl Tool for RoutineListTool {
.list_routines(&ctx.user_id)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = self
.store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to read routine statuses: {e}"))
})?;
let list: Vec<serde_json::Value> = routines
.iter()
.map(|r| {
let verification_status = routine_verification_status(r);
let status = crate::agent::routine::routine_display_status_for_verification(
r,
verification_status,
last_run_statuses.get(&r.id).copied(),
);
serde_json::json!({
"id": r.id.to_string(),
"name": r.name,
@@ -1260,8 +1239,6 @@ impl Tool for RoutineListTool {
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
"run_count": r.run_count,
"consecutive_failures": r.consecutive_failures,
"status": status.as_str(),
"verification_status": verification_status.as_str(),
})
})
.collect();
@@ -1300,8 +1277,7 @@ impl Tool for RoutineUpdateTool {
fn description(&self) -> &str {
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
Pass the routine name and only the fields you want to change. This does not convert trigger types. \
Behavior-changing edits should leave the routine marked unverified until it is tested again."
Pass the routine name and only the fields you want to change. This does not convert trigger types."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -1316,6 +1292,7 @@ impl Tool for RoutineUpdateTool {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
stash_last_routine_name(ctx, name).await;
let mut routine = self
.store
@@ -1324,9 +1301,6 @@ impl Tool for RoutineUpdateTool {
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let original_fingerprint = routine_verification_fingerprint(&routine);
let mut verification_reset = false;
// Apply updates
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
routine.enabled = enabled;
@@ -1338,18 +1312,8 @@ impl Tool for RoutineUpdateTool {
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
match &mut routine.action {
RoutineAction::Lightweight { prompt: p, .. } => {
if p != prompt {
verification_reset = true;
*p = prompt.to_string();
}
}
RoutineAction::FullJob { description: d, .. } => {
if d != prompt {
verification_reset = true;
*d = prompt.to_string();
}
}
RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(),
RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(),
}
}
@@ -1380,16 +1344,12 @@ impl Tool for RoutineUpdateTool {
if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
let effective_tz = new_timezone.clone().or(old_tz.clone());
let effective_tz = new_timezone.or(old_tz);
// Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
if effective_schedule != old_schedule || effective_tz != old_tz {
verification_reset = true;
}
routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(),
@@ -1403,12 +1363,6 @@ impl Tool for RoutineUpdateTool {
}
}
let updated_fingerprint = routine_verification_fingerprint(&routine);
if updated_fingerprint != original_fingerprint {
verification_reset = true;
routine.state = reset_routine_verification_state(&routine.state, updated_fingerprint);
}
self.store
.update_routine(&routine)
.await
@@ -1417,14 +1371,12 @@ impl Tool for RoutineUpdateTool {
// Refresh event cache in case trigger changed
self.engine.refresh_event_cache().await;
let verification = verification_result_payload(&routine, verification_reset);
let result = serde_json::json!({
"name": routine.name.clone(),
"name": routine.name,
"enabled": routine.enabled,
"trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "updated",
"verification": verification,
});
Ok(ToolOutput::success(result, start.elapsed()))
@@ -1478,11 +1430,24 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"'name' parameter cannot be empty".to_string(),
));
}
name.to_string()
} else {
restore_last_routine_name(ctx).await.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'name' parameter and no previous routine target to infer".to_string(),
)
})?
};
let routine = self
.store
.get_routine_by_name(&ctx.user_id, name)
.get_routine_by_name(&ctx.user_id, &name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
@@ -1497,7 +1462,7 @@ impl Tool for RoutineDeleteTool {
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": name,
"name": &name,
"deleted": deleted,
});
+19 -1
View File
@@ -117,6 +117,11 @@ impl McpClient {
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
///
/// Returns an error if the config uses a non-HTTP transport.
///
/// **Note:** The session manager is NOT wired into the transport. For
/// production use, prefer `create_client_from_config()` which constructs
/// the transport with session tracking.
#[cfg(test)]
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
if !matches!(
config.effective_transport(),
@@ -214,7 +219,14 @@ impl McpClient {
}
}
/// Attach a session manager for Streamable HTTP session tracking.
/// Attach a session manager to the **client** only.
///
/// **Warning:** This does NOT wire the session manager into the underlying
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
/// from responses. For production use, construct the transport with
/// `HttpMcpTransport::with_session_manager()` and pass it to
/// `new_with_transport()` instead. See `create_client_from_config()`.
#[cfg(test)]
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
@@ -235,6 +247,12 @@ impl McpClient {
self.session_manager.is_some()
}
/// Get the underlying transport (test-only).
#[cfg(test)]
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
&self.transport
}
/// Get the next request ID.
fn next_request_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
+101 -16
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use crate::secrets::SecretsStore;
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
use crate::tools::mcp::http_transport::HttpMcpTransport;
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
/// Error returned when MCP client creation fails.
@@ -78,33 +79,37 @@ pub async fn create_client_from_config(
Err(McpFactoryError::UnixNotSupported { name: server_name })
}
EffectiveTransport::Http => {
// Authenticated (OAuth) path: tokens exist or server requires auth.
if let Some(ref secrets) = secrets {
let has_tokens =
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
if has_tokens || server.requires_auth() {
Ok(McpClient::new_authenticated(
return Ok(McpClient::new_authenticated(
server,
Arc::clone(session_manager),
Arc::clone(secrets),
user_id,
))
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name.clone(),
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
));
}
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name,
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
// Non-OAuth HTTP: wire the session manager into the *transport* so
// it captures `Mcp-Session-Id` from responses. Passing it only to
// the client (via `with_session_manager`) is not enough — the
// transport must know about it to read/write the header.
let transport = Arc::new(
HttpMcpTransport::new(server.url.clone(), server.name.clone())
.with_session_manager(Arc::clone(session_manager)),
);
Ok(McpClient::new_with_transport(
server.name.clone(),
transport,
Some(Arc::clone(session_manager)),
secrets,
user_id,
Some(server),
))
}
}
}
@@ -134,4 +139,84 @@ mod tests {
"non-OAuth HTTP clients must carry a session manager"
);
}
/// Regression test: the factory must wire the session manager into the
/// *transport*, not just the client. Otherwise the transport never
/// captures `Mcp-Session-Id` from responses and subsequent requests
/// lack the header, causing the server to reject them.
#[tokio::test]
async fn test_factory_non_oauth_http_transport_captures_session_id() {
use axum::http::header::HeaderName;
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
use tokio::net::TcpListener;
const SESSION_ID: &str = "test-session-abc123";
async fn session_echo() -> impl IntoResponse {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {}
})
.to_string();
(
StatusCode::OK,
[(
HeaderName::from_static("mcp-session-id"),
SESSION_ID.to_string(),
)],
body,
)
}
let app = Router::new().route("/", post(session_echo));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let server = McpServerConfig::new("session-test", &url);
let session_manager = Arc::new(McpSessionManager::new());
let process_manager = Arc::new(McpProcessManager::new());
let client = create_client_from_config(
server,
&session_manager,
&process_manager,
None,
"test-user",
)
.await
.expect("factory should succeed for HTTP config");
// Pre-create a session entry so that update_session_id has something to update.
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
session_manager.get_or_create("session-test", &url).await;
// Send a request through the client's transport to trigger session capture.
use crate::tools::mcp::protocol::McpRequest;
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "test".to_string(),
params: Some(serde_json::json!({})),
};
let headers = std::collections::HashMap::new();
client
.transport()
.send(&request, &headers)
.await
.expect("request should succeed");
// Verify the session manager captured the session ID from the response.
let captured = session_manager.get_session_id("session-test").await;
assert_eq!(
captured.as_deref(),
Some(SESSION_ID),
"transport must capture Mcp-Session-Id into session manager"
);
}
}
+28
View File
@@ -494,6 +494,34 @@ mod tests {
assert_eq!(echoed["authorization"], "Bearer oauth-token");
}
/// Regression test for #1436: 202 Accepted responses for notifications
/// were parsed as JSON, causing "Failed to parse MCP response" errors
/// that broke the MCP session handshake.
#[tokio::test]
async fn test_wire_202_accepted_for_notification() {
use axum::{Router, http::StatusCode, routing::post};
use tokio::net::TcpListener;
async fn accept_notification() -> StatusCode {
StatusCode::ACCEPTED
}
let app = Router::new().route("/", post(accept_notification));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let transport = HttpMcpTransport::new(&url, "test-202");
let request = McpRequest::initialized_notification();
let response = transport.send(&request, &HashMap::new()).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
let (url, _handle) = spawn_echo_server().await;
+40 -3
View File
@@ -205,7 +205,44 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_manual_create_defaults_to_tools_enabled
// Test 5: routine_update_fail_delete_fallback
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_update_fail_delete_fallback() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
))
.expect("failed to load routine_update_fail_delete_fallback.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Try converting a routine trigger, then recover by deleting it")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
"routine_update should fail in this regression path: {completed:?}"
);
assert!(
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_defaults_to_tools_enabled
// -----------------------------------------------------------------------
#[tokio::test]
@@ -246,7 +283,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_explicit_no_tools
// Test 7: routine_manual_create_explicit_no_tools
// -----------------------------------------------------------------------
#[tokio::test]
@@ -287,7 +324,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_history
// Test 8: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -29,7 +29,7 @@
{
"response": {
"type": "text",
"content": "Created the any-channel-bug-watcher routine for bug messages, but it is not verified yet. It should stay unverified until it has a successful run.",
"content": "Created the any-channel-bug-watcher routine for bug messages.",
"input_tokens": 170,
"output_tokens": 18
}
@@ -30,7 +30,7 @@
{
"response": {
"type": "text",
"content": "Created the telegram-bug-watcher routine for Telegram bug messages, but it is not verified yet. I can test it the next time you want to fire it.",
"content": "Created the telegram-bug-watcher routine for Telegram bug messages.",
"input_tokens": 180,
"output_tokens": 20
}
@@ -37,7 +37,7 @@
{
"response": {
"type": "text",
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are available, but the routine is not verified yet.",
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are pre-authorized.",
"input_tokens": 200,
"output_tokens": 50
}
@@ -57,7 +57,7 @@
{
"response": {
"type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the routines. It is not verified yet, so it should stay unverified until it has a successful run.",
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
"input_tokens": 250,
"output_tokens": 24
}
+1 -1
View File
@@ -52,7 +52,7 @@
{
"response": {
"type": "text",
"content": "I created the daily-check routine, but it is not verified yet. It is scheduled for 9 AM every day, and the routine list should show it as unverified until it has a successful run.",
"content": "I created a daily-check routine that runs at 9 AM every day. The routine list shows it as active.",
"input_tokens": 300,
"output_tokens": 25
}
+1 -1
View File
@@ -41,7 +41,7 @@
{
"response": {
"type": "text",
"content": "The history-test routine was created, but it is not verified yet. Its run history is empty since it hasn't been triggered yet.",
"content": "The history-test routine was created. Its run history is empty since it hasn't been triggered yet.",
"input_tokens": 300,
"output_tokens": 25
}
+1 -1
View File
@@ -27,7 +27,7 @@
{
"response": {
"type": "text",
"content": "Created the manual-triage routine, but it is not verified yet. It will only run when explicitly fired, so I can test it for you when you're ready.",
"content": "Created the manual-triage routine. It will only run when explicitly fired.",
"input_tokens": 140,
"output_tokens": 18
}
@@ -30,7 +30,7 @@
{
"response": {
"type": "text",
"content": "Created the manual-triage-no-tools routine, but it is not verified yet. It will only run when explicitly fired and stay text-only until you decide to test it.",
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
"input_tokens": 140,
"output_tokens": 18
}
+1 -1
View File
@@ -59,7 +59,7 @@
{
"response": {
"type": "text",
"content": "Created, updated, and then deleted the temp-routine successfully. The update would have left it unverified until it was tested again.",
"content": "Created, updated, and then deleted the temp-routine successfully.",
"input_tokens": 400,
"output_tokens": 20
}
@@ -0,0 +1,70 @@
{
"model_name": "test-routine-update-fail-delete-fallback",
"expects": {
"tools_used": ["routine_create", "routine_update", "routine_delete"],
"tool_results_contain": {
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
"routine_delete": "temp-routine"
},
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_fallback",
"name": "routine_create",
"arguments": {
"name": "temp-routine",
"trigger_type": "manual",
"prompt": "Temporary routine for fallback test."
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ru_fallback",
"name": "routine_update",
"arguments": {
"name": "temp-routine",
"schedule": "0 */10 * * * *"
}
}
],
"input_tokens": 200,
"output_tokens": 30
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rd_fallback",
"name": "routine_delete",
"arguments": {}
}
],
"input_tokens": 300,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I recovered from the failed update and cleaned up the original routine.",
"input_tokens": 380,
"output_tokens": 25
}
}
]
}
-112
View File
@@ -16,7 +16,6 @@ mod tests {
use chrono::Utc;
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
reset_routine_verification_state, routine_verification_fingerprint,
};
use uuid::Uuid;
@@ -339,115 +338,4 @@ mod tests {
harness.shutdown().await;
mock.shutdown().await;
}
#[tokio::test]
async fn routines_api_surfaces_unverified_status_for_new_routine() {
let mock = MockOpenAiServerBuilder::new()
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let mut routine = Routine {
id: Uuid::new_v4(),
name: "wf-unverified".to_string(),
description: "Unverified status regression test".to_string(),
user_id: harness.user_id.clone(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check verification status".to_string(),
context_paths: Vec::new(),
max_tokens: 512,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
harness
.db
.create_routine(&routine)
.await
.expect("create routine");
let mut disabled_routine = routine.clone();
disabled_routine.id = Uuid::new_v4();
disabled_routine.name = "wf-unverified-disabled".to_string();
disabled_routine.enabled = false;
disabled_routine.state = reset_routine_verification_state(
&disabled_routine.state,
routine_verification_fingerprint(&disabled_routine),
);
harness
.db
.create_routine(&disabled_routine)
.await
.expect("create disabled routine");
let list = harness.list_routines().await;
let routine_id = routine.id.to_string();
let listed = list["routines"]
.as_array()
.expect("routines array")
.iter()
.find(|item| item["id"].as_str() == Some(routine_id.as_str()))
.expect("routine should be listed");
assert_eq!(listed["status"].as_str(), Some("unverified"));
assert_eq!(listed["verification_status"].as_str(), Some("unverified"));
let summary = harness
.client
.get(format!("{}/api/routines/summary", harness.base_url()))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("summary request failed")
.error_for_status()
.expect("summary non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid summary response");
assert_eq!(summary["unverified"].as_u64(), Some(2));
let detail = harness
.client
.get(format!(
"{}/api/routines/{}",
harness.base_url(),
routine_id
))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert_eq!(detail["status"].as_str(), Some("unverified"));
assert_eq!(detail["verification_status"].as_str(), Some("unverified"));
harness.shutdown().await;
mock.shutdown().await;
}
}