mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c90af0c3a | ||
|
|
3dce7cf224 | ||
|
|
4a9daf704d | ||
|
|
299e8e0f11 |
@@ -7,24 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.23.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.22.0...ironclaw-v0.23.0) - 2026-03-27
|
||||
|
||||
### Added
|
||||
|
||||
- complete multi-tenant isolation — phases 2–4 ([#1614](https://github.com/nearai/ironclaw/pull/1614))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(routines)* recover delete name after failed update fallback ([#1108](https://github.com/nearai/ironclaw/pull/1108))
|
||||
- *(mcp)* handle 202 Accepted and wire session manager for Streamable HTTP ([#1437](https://github.com/nearai/ironclaw/pull/1437))
|
||||
- *(extensions)* channel-relay auth dead-end, observability, and URL override ([#1681](https://github.com/nearai/ironclaw/pull/1681))
|
||||
- *(agent)* discard truncated tool calls when finish_reason == Length ([#1631](https://github.com/nearai/ironclaw/pull/1631)) ([#1632](https://github.com/nearai/ironclaw/pull/1632))
|
||||
- *(llm)* filter XML tool-call recovery by context ([#1641](https://github.com/nearai/ironclaw/pull/1641))
|
||||
|
||||
### Other
|
||||
|
||||
- Support direct hosted OAuth callbacks with proxy auth token ([#1684](https://github.com/nearai/ironclaw/pull/1684))
|
||||
|
||||
## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -3390,7 +3390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.23.0"
|
||||
version = "0.22.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.23.0"
|
||||
version = "0.22.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
+439
-2
@@ -24,6 +24,8 @@ 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;
|
||||
@@ -52,6 +54,55 @@ 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")]
|
||||
@@ -517,6 +568,155 @@ 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`.
|
||||
@@ -725,9 +925,14 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||
describe_cron, next_cron_fire, normalize_cron_expression,
|
||||
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,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_trigger_roundtrip() {
|
||||
@@ -861,6 +1066,69 @@ 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
|
||||
@@ -1117,4 +1385,173 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::Scheduler;
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
|
||||
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
|
||||
};
|
||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||
use crate::config::RoutineConfig;
|
||||
@@ -621,7 +622,7 @@ impl RoutineEngine {
|
||||
);
|
||||
|
||||
// Load the routine to update consecutive_failures and send notification
|
||||
let routine = match self.store.get_routine(run.routine_id).await {
|
||||
let mut routine = match self.store.get_routine(run.routine_id).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
@@ -649,6 +650,12 @@ 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,
|
||||
@@ -1085,7 +1092,7 @@ struct EngineContext {
|
||||
}
|
||||
|
||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
|
||||
async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) {
|
||||
// Increment running count (atomic: survives panics in the execution below)
|
||||
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -1143,8 +1150,15 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
||||
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
|
||||
}
|
||||
|
||||
// Update routine runtime state
|
||||
let now = Utc::now();
|
||||
routine.state = apply_routine_verification_result(
|
||||
&routine.state,
|
||||
routine_verification_fingerprint(&routine),
|
||||
status,
|
||||
now,
|
||||
);
|
||||
|
||||
// Update routine runtime state
|
||||
let next_fire = if let Trigger::Cron {
|
||||
ref schedule,
|
||||
ref timezone,
|
||||
|
||||
@@ -122,32 +122,18 @@ impl RelayClient {
|
||||
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
||||
/// for validating the callback — no URLs.
|
||||
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
||||
let url = format!("{}/oauth/slack/auth", self.base_url);
|
||||
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
|
||||
let mut query: Vec<(&str, &str)> = vec![];
|
||||
if let Some(nonce) = state_nonce {
|
||||
query.push(("state_nonce", nonce));
|
||||
}
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.get(format!("{}/oauth/slack/auth", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::initiate_oauth: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
status = %resp.status(),
|
||||
"RelayClient::initiate_oauth: received response"
|
||||
);
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_redirection() {
|
||||
@@ -238,39 +224,20 @@ impl RelayClient {
|
||||
method: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, RelayError> {
|
||||
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
provider = %provider,
|
||||
method = %method,
|
||||
"RelayClient::proxy_provider: sending request"
|
||||
);
|
||||
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::proxy_provider: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
status = status,
|
||||
"RelayClient::proxy_provider: channel-relay returned error"
|
||||
);
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
message: body,
|
||||
@@ -288,45 +255,23 @@ impl RelayClient {
|
||||
/// 32-byte secret. Called once at activation time; the result is cached in the
|
||||
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
||||
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
||||
let url = format!("{}/relay/signing-secret", self.base_url);
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
"RelayClient::get_signing_secret: fetching signing secret"
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.get(format!("{}/relay/signing-secret", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&[("team_id", team_id)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::get_signing_secret: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
status = status,
|
||||
body = %body,
|
||||
"RelayClient::get_signing_secret: channel-relay returned error"
|
||||
);
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
message: body,
|
||||
});
|
||||
}
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
"RelayClient::get_signing_secret: received successful response"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
|
||||
@@ -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/failing/runs_today) |
|
||||
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/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 |
|
||||
|
||||
@@ -10,7 +10,10 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
||||
use crate::agent::routine::{
|
||||
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
|
||||
routine_display_status_for_verification, routine_verification_status,
|
||||
};
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
@@ -30,7 +33,18 @@ pub async fn routines_list_handler(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
||||
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();
|
||||
|
||||
Ok(Json(RoutineListResponse { routines: items }))
|
||||
}
|
||||
@@ -49,13 +63,39 @@ 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 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 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 today_start = chrono::Utc::now()
|
||||
.date_naive()
|
||||
@@ -74,6 +114,7 @@ pub async fn routines_summary_handler(
|
||||
total,
|
||||
enabled,
|
||||
disabled,
|
||||
unverified,
|
||||
failing,
|
||||
runs_today,
|
||||
}))
|
||||
@@ -120,7 +161,7 @@ pub async fn routines_detail_handler(
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
let routine_info = RoutineInfo::from_routine(&routine);
|
||||
let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status));
|
||||
|
||||
Ok(Json(RoutineDetailResponse {
|
||||
id: routine.id,
|
||||
@@ -138,6 +179,8 @@ 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,
|
||||
}))
|
||||
|
||||
+5
-484
@@ -836,10 +836,10 @@ async fn oauth_callback_handler(
|
||||
|
||||
let result: Result<(), String> = async {
|
||||
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
|
||||
let oauth_proxy_auth_token = flow.oauth_proxy_auth_token().unwrap_or_default();
|
||||
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
||||
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
|
||||
proxy_url,
|
||||
gateway_token: oauth_proxy_auth_token,
|
||||
gateway_token,
|
||||
token_url: &flow.token_url,
|
||||
client_id: &flow.client_id,
|
||||
client_secret: flow.client_secret.as_deref(),
|
||||
@@ -1177,31 +1177,11 @@ async fn slack_relay_oauth_callback_handler(
|
||||
|
||||
// Store team_id in settings
|
||||
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
||||
tracing::info!(
|
||||
relay = DEFAULT_RELAY_NAME,
|
||||
owner_id = %state.owner_id,
|
||||
team_id_key = %team_id_key,
|
||||
"relay OAuth callback: storing team_id in settings"
|
||||
);
|
||||
store
|
||||
let _ = store
|
||||
.set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
relay = DEFAULT_RELAY_NAME,
|
||||
owner_id = %state.owner_id,
|
||||
error = %e,
|
||||
"relay OAuth callback: failed to persist team_id to settings store"
|
||||
);
|
||||
format!("Failed to persist relay team_id: {e}")
|
||||
})?;
|
||||
.await;
|
||||
|
||||
// Activate the relay channel
|
||||
tracing::info!(
|
||||
relay = DEFAULT_RELAY_NAME,
|
||||
owner_id = %state.owner_id,
|
||||
"relay OAuth callback: activating relay channel"
|
||||
);
|
||||
ext_mgr
|
||||
.activate_stored_relay(DEFAULT_RELAY_NAME, &state.owner_id)
|
||||
.await
|
||||
@@ -2201,11 +2181,6 @@ async fn extensions_activate_handler(
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
user_id = %user.user_id,
|
||||
"extensions_activate_handler: received activate request"
|
||||
);
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
@@ -2213,10 +2188,6 @@ async fn extensions_activate_handler(
|
||||
|
||||
match ext_mgr.activate(&name, &user.user_id).await {
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
"extensions_activate_handler: activation succeeded"
|
||||
);
|
||||
// Activation loaded the WASM module. Check if the tool needs
|
||||
// OAuth scope expansion (e.g., adding google-docs when gmail
|
||||
// already has a token but missing the documents scope).
|
||||
@@ -2235,13 +2206,6 @@ async fn extensions_activate_handler(
|
||||
crate::extensions::ExtensionError::AuthRequired
|
||||
);
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
error = %activate_err,
|
||||
needs_auth = needs_auth,
|
||||
"extensions_activate_handler: activation failed, attempting auth fallback"
|
||||
);
|
||||
|
||||
if !needs_auth {
|
||||
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
|
||||
}
|
||||
@@ -2249,21 +2213,10 @@ async fn extensions_activate_handler(
|
||||
// Activation failed due to auth; try authenticating first.
|
||||
match ext_mgr.auth(&name, &user.user_id).await {
|
||||
Ok(auth_result) if auth_result.is_authenticated() => {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"extensions_activate_handler: auth reports authenticated, retrying activate"
|
||||
);
|
||||
// Auth succeeded, retry activation.
|
||||
match ext_mgr.activate(&name, &user.user_id).await {
|
||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"extensions_activate_handler: retry after auth still failed"
|
||||
);
|
||||
Ok(Json(ActionResponse::fail(e.to_string())))
|
||||
}
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
Ok(auth_result) => {
|
||||
@@ -3057,160 +3010,6 @@ mod tests {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RecordedOauthProxyRequest {
|
||||
authorization: Option<String>,
|
||||
form: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockOauthProxyState {
|
||||
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
|
||||
}
|
||||
|
||||
struct MockOauthProxyServer {
|
||||
addr: std::net::SocketAddr,
|
||||
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MockOauthProxyServer {
|
||||
async fn start() -> Self {
|
||||
async fn exchange_handler(
|
||||
State(state): State<MockOauthProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Form(form): axum::Form<std::collections::HashMap<String, String>>,
|
||||
) -> Json<serde_json::Value> {
|
||||
state.requests.lock().await.push(RecordedOauthProxyRequest {
|
||||
authorization: headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string),
|
||||
form,
|
||||
});
|
||||
Json(serde_json::json!({
|
||||
"access_token": "proxy-access-token",
|
||||
"refresh_token": "proxy-refresh-token",
|
||||
"expires_in": 7200
|
||||
}))
|
||||
}
|
||||
|
||||
let requests = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock oauth proxy");
|
||||
let addr = listener.local_addr().expect("mock oauth proxy addr");
|
||||
let app = Router::new()
|
||||
.route("/oauth/exchange", post(exchange_handler))
|
||||
.with_state(MockOauthProxyState {
|
||||
requests: Arc::clone(&requests),
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
Self {
|
||||
addr,
|
||||
requests,
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
server_task: Some(server_task),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
async fn requests(&self) -> Vec<RecordedOauthProxyRequest> {
|
||||
self.requests.lock().await.clone()
|
||||
}
|
||||
|
||||
async fn shutdown(mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(task) = self.server_task.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockOauthProxyServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(task) = self.server_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Tests use lock_env() to serialize environment access.
|
||||
unsafe {
|
||||
if let Some(ref value) = self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
||||
let original = std::env::var(key).ok();
|
||||
// SAFETY: Tests use lock_env() to serialize environment access.
|
||||
unsafe {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
EnvVarGuard { key, original }
|
||||
}
|
||||
|
||||
fn fresh_pending_oauth_flow(
|
||||
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
sse_manager: Option<Arc<SseManager>>,
|
||||
oauth_proxy_auth_token: Option<String>,
|
||||
) -> crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||
crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||
extension_name: "test_tool".to_string(),
|
||||
display_name: "Test Tool".to_string(),
|
||||
token_url: "https://example.com/token".to_string(),
|
||||
client_id: "client123".to_string(),
|
||||
client_secret: None,
|
||||
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||
code_verifier: Some("test-code-verifier".to_string()),
|
||||
access_token_field: "access_token".to_string(),
|
||||
secret_name: "test_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
validation_endpoint: None,
|
||||
scopes: vec!["email".to_string()],
|
||||
user_id: "test".to_string(),
|
||||
secrets,
|
||||
sse_manager,
|
||||
gateway_token: oauth_proxy_auth_token,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
client_id_secret_name: None,
|
||||
created_at: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
|
||||
use axum::body::Body;
|
||||
@@ -3868,284 +3667,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_accepts_versioned_hosted_state_without_instance_name() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
||||
|
||||
let Some(created_at) = expired_flow_created_at() else {
|
||||
eprintln!(
|
||||
"Skipping versioned OAuth state without instance test: monotonic uptime below expiry window"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||
extension_name: "test_tool".to_string(),
|
||||
display_name: "Test Tool".to_string(),
|
||||
token_url: "https://example.com/token".to_string(),
|
||||
client_id: "client123".to_string(),
|
||||
client_secret: None,
|
||||
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||
code_verifier: None,
|
||||
access_token_field: "access_token".to_string(),
|
||||
secret_name: "test_token".to_string(),
|
||||
provider: None,
|
||||
validation_endpoint: None,
|
||||
scopes: vec![],
|
||||
user_id: "test".to_string(),
|
||||
secrets,
|
||||
sse_manager: None,
|
||||
gateway_token: None,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
client_id_secret_name: None,
|
||||
created_at,
|
||||
};
|
||||
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.write()
|
||||
.await
|
||||
.insert("test_nonce".to_string(), flow);
|
||||
|
||||
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||
let app = test_oauth_router(state);
|
||||
let versioned_state =
|
||||
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/oauth/callback?code=fake_code&state={}",
|
||||
urlencoding::encode(&versioned_state)
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Authorization Failed"));
|
||||
assert!(
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.read()
|
||||
.await
|
||||
.get("test_nonce")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_happy_path_with_gateway_token_fallback() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let proxy = MockOauthProxyServer::start().await;
|
||||
// Keep the process-wide env locked for the full callback so the handler
|
||||
// sees a stable proxy URL/token configuration throughout the test.
|
||||
let _env_guard = crate::config::helpers::lock_env();
|
||||
let _exchange_url_guard =
|
||||
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
|
||||
let _proxy_auth_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||
|
||||
let secrets = test_secrets_store();
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
|
||||
let sse_mgr = Arc::new(SseManager::new());
|
||||
let mut receiver = sse_mgr.sender().subscribe();
|
||||
let flow = fresh_pending_oauth_flow(
|
||||
Arc::clone(&secrets),
|
||||
Some(Arc::clone(&sse_mgr)),
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||
);
|
||||
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.write()
|
||||
.await
|
||||
.insert("test_nonce".to_string(), flow);
|
||||
|
||||
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||
let app = test_oauth_router(state);
|
||||
let versioned_state =
|
||||
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/oauth/callback?code=fake_code&state={}",
|
||||
urlencoding::encode(&versioned_state)
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Test Tool Connected"));
|
||||
|
||||
let requests = proxy.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization.as_deref(),
|
||||
Some("Bearer gateway-test-token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code").map(String::as_str),
|
||||
Some("fake_code")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code_verifier").map(String::as_str),
|
||||
Some("test-code-verifier")
|
||||
);
|
||||
|
||||
let access_token = secrets
|
||||
.get_decrypted("test", "test_token")
|
||||
.await
|
||||
.expect("access token stored");
|
||||
assert_eq!(access_token.expose(), "proxy-access-token");
|
||||
|
||||
let refresh_token = secrets
|
||||
.get_decrypted("test", "test_token_refresh_token")
|
||||
.await
|
||||
.expect("refresh token stored");
|
||||
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
|
||||
|
||||
match receiver.recv().await.expect("auth_completed event").event {
|
||||
crate::channels::web::types::AppEvent::AuthCompleted {
|
||||
extension_name,
|
||||
success,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(extension_name, "test_tool");
|
||||
assert!(success, "OAuth callback should broadcast success");
|
||||
}
|
||||
event => panic!("expected AuthCompleted event, got {event:?}"),
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_happy_path_with_dedicated_proxy_auth_token() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let proxy = MockOauthProxyServer::start().await;
|
||||
// Keep the process-wide env locked for the full callback so the handler
|
||||
// sees a stable proxy URL/token configuration throughout the test.
|
||||
let _env_guard = crate::config::helpers::lock_env();
|
||||
let _exchange_url_guard =
|
||||
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
|
||||
let _proxy_auth_guard = set_env_var(
|
||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||
Some("shared-oauth-proxy-secret"),
|
||||
);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||
|
||||
let secrets = test_secrets_store();
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
|
||||
let sse_mgr = Arc::new(SseManager::new());
|
||||
let mut receiver = sse_mgr.sender().subscribe();
|
||||
let flow = fresh_pending_oauth_flow(
|
||||
Arc::clone(&secrets),
|
||||
Some(Arc::clone(&sse_mgr)),
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||
);
|
||||
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.write()
|
||||
.await
|
||||
.insert("test_nonce".to_string(), flow);
|
||||
|
||||
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||
let app = test_oauth_router(state);
|
||||
let versioned_state =
|
||||
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/oauth/callback?code=fake_code&state={}",
|
||||
urlencoding::encode(&versioned_state)
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Test Tool Connected"));
|
||||
|
||||
let requests = proxy.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization.as_deref(),
|
||||
Some("Bearer shared-oauth-proxy-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code").map(String::as_str),
|
||||
Some("fake_code")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code_verifier").map(String::as_str),
|
||||
Some("test-code-verifier")
|
||||
);
|
||||
|
||||
let access_token = secrets
|
||||
.get_decrypted("test", "test_token")
|
||||
.await
|
||||
.expect("access token stored");
|
||||
assert_eq!(access_token.expose(), "proxy-access-token");
|
||||
|
||||
let refresh_token = secrets
|
||||
.get_decrypted("test", "test_token_refresh_token")
|
||||
.await
|
||||
.expect("refresh token stored");
|
||||
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
|
||||
|
||||
match receiver.recv().await.expect("auth_completed event").event {
|
||||
crate::channels::web::types::AppEvent::AuthCompleted {
|
||||
extension_name,
|
||||
success,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(extension_name, "test_tool");
|
||||
assert!(success, "OAuth callback should broadcast success");
|
||||
}
|
||||
event => panic!("expected AuthCompleted event, got {event:?}"),
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
// --- Slack relay OAuth CSRF tests ---
|
||||
|
||||
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
||||
|
||||
@@ -4141,6 +4141,7 @@ 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');
|
||||
}
|
||||
@@ -4159,6 +4160,8 @@ 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';
|
||||
@@ -4166,6 +4169,9 @@ 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>'
|
||||
@@ -4177,7 +4183,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) + '">Run</button> '
|
||||
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> '
|
||||
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
@@ -4206,12 +4212,12 @@ function renderRoutineDetail(routine) {
|
||||
const detail = document.getElementById('routine-detail');
|
||||
detail.style.display = 'block';
|
||||
|
||||
const statusClass = !routine.enabled ? 'pending'
|
||||
: routine.consecutive_failures > 0 ? 'failed'
|
||||
: 'completed';
|
||||
const statusLabel = !routine.enabled ? 'disabled'
|
||||
: routine.consecutive_failures > 0 ? 'failing'
|
||||
: 'active';
|
||||
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';
|
||||
|
||||
let html = '<div class="job-detail-header">'
|
||||
+ '<button class="btn-back" data-action="close-routine-detail">← Back</button>'
|
||||
@@ -4236,6 +4242,20 @@ 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';
|
||||
|
||||
@@ -207,6 +207,7 @@ 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',
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ I18n.register('zh-CN', {
|
||||
'routines.summary.total': '总计',
|
||||
'routines.summary.enabled': '已启用',
|
||||
'routines.summary.disabled': '已禁用',
|
||||
'routines.summary.unverified': '未验证',
|
||||
'routines.summary.failing': '失败',
|
||||
'routines.summary.runsToday': '今日运行',
|
||||
|
||||
|
||||
+143
-8
@@ -662,11 +662,15 @@ 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) -> Self {
|
||||
pub fn from_routine(
|
||||
r: &crate::agent::routine::Routine,
|
||||
last_run_status: Option<crate::agent::routine::RunStatus>,
|
||||
) -> Self {
|
||||
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
|
||||
crate::agent::routine::Trigger::Cron { schedule, timezone } => (
|
||||
"cron".to_string(),
|
||||
@@ -710,13 +714,13 @@ impl RoutineInfo {
|
||||
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
|
||||
};
|
||||
|
||||
let status = if !r.enabled {
|
||||
"disabled"
|
||||
} else if r.consecutive_failures > 0 {
|
||||
"failing"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
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();
|
||||
|
||||
RoutineInfo {
|
||||
id: r.id,
|
||||
@@ -732,6 +736,7 @@ impl RoutineInfo {
|
||||
run_count: r.run_count,
|
||||
consecutive_failures: r.consecutive_failures,
|
||||
status: status.to_string(),
|
||||
verification_status: verification_status.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,6 +751,7 @@ pub struct RoutineSummaryResponse {
|
||||
pub total: u64,
|
||||
pub enabled: u64,
|
||||
pub disabled: u64,
|
||||
pub unverified: u64,
|
||||
pub failing: u64,
|
||||
pub runs_today: u64,
|
||||
}
|
||||
@@ -767,6 +773,8 @@ 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>,
|
||||
}
|
||||
@@ -823,6 +831,7 @@ pub struct HealthResponse {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
// ---- WsClientMessage deserialization tests ----
|
||||
|
||||
@@ -1173,4 +1182,130 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-184
@@ -473,8 +473,7 @@ pub struct PendingOAuthFlow {
|
||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||
/// SSE broadcast manager for notifying the web UI.
|
||||
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||
pub gateway_token: Option<String>,
|
||||
/// Additional form params for the token exchange request.
|
||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||
@@ -497,12 +496,6 @@ impl std::fmt::Debug for PendingOAuthFlow {
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingOAuthFlow {
|
||||
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
||||
self.gateway_token.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
||||
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
||||
|
||||
@@ -536,22 +529,6 @@ pub fn exchange_proxy_url() -> Option<String> {
|
||||
.filter(|url| !url.is_empty())
|
||||
}
|
||||
|
||||
/// Returns the configured OAuth proxy auth token, if any.
|
||||
///
|
||||
/// New hosted infra can inject a dedicated shared proxy secret via
|
||||
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
|
||||
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
|
||||
pub fn oauth_proxy_auth_token() -> Option<String> {
|
||||
fn normalized_env_value(key: &str) -> Option<String> {
|
||||
crate::config::helpers::env_or_override(key)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
|
||||
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
|
||||
}
|
||||
|
||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||
|
||||
@@ -697,8 +674,6 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
||||
|
||||
pub struct ProxyTokenExchangeRequest<'a> {
|
||||
pub proxy_url: &'a str,
|
||||
/// OAuth proxy auth token.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
pub gateway_token: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
@@ -712,8 +687,6 @@ pub struct ProxyTokenExchangeRequest<'a> {
|
||||
|
||||
pub struct ProxyRefreshTokenRequest<'a> {
|
||||
pub proxy_url: &'a str,
|
||||
/// OAuth proxy auth token.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
pub gateway_token: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
@@ -756,7 +729,7 @@ fn oauth_token_response_from_json(
|
||||
|
||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||
///
|
||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||
/// the provider requires it.
|
||||
///
|
||||
@@ -768,7 +741,7 @@ pub async fn exchange_via_proxy(
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
if request.gateway_token.is_empty() {
|
||||
return Err(OAuthCallbackError::Io(
|
||||
"OAuth proxy auth token is required for proxy token exchange".to_string(),
|
||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
||||
));
|
||||
}
|
||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
||||
@@ -823,7 +796,7 @@ pub async fn exchange_via_proxy(
|
||||
|
||||
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
||||
///
|
||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||
/// the provider requires it.
|
||||
pub async fn refresh_token_via_proxy(
|
||||
@@ -831,7 +804,7 @@ pub async fn refresh_token_via_proxy(
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
if request.gateway_token.is_empty() {
|
||||
return Err(OAuthCallbackError::Io(
|
||||
"OAuth proxy auth token is required for proxy token refresh".to_string(),
|
||||
"Gateway auth token is required for proxy token refresh".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1037,37 +1010,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
if let Some(ref value) = self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
||||
let original = std::env::var(key).ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
EnvVarGuard { key, original }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
||||
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
||||
@@ -1088,79 +1030,6 @@ mod tests {
|
||||
assert_eq!(result, client_secret);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exchange_via_proxy_sends_auth_and_form() {
|
||||
let server = MockProxyServer::start().await;
|
||||
let mut extra_token_params = HashMap::new();
|
||||
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
|
||||
|
||||
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
|
||||
proxy_url: &server.base_url(),
|
||||
gateway_token: "shared-oauth-proxy-secret",
|
||||
code: "auth-code-123",
|
||||
redirect_uri: "https://oauth.example.com/oauth/callback",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
client_id: TEST_OAUTH_CLIENT_ID,
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||
access_token_field: "access_token",
|
||||
code_verifier: Some("code-verifier-123"),
|
||||
extra_token_params: &extra_token_params,
|
||||
})
|
||||
.await
|
||||
.expect("proxy exchange succeeds");
|
||||
|
||||
assert_eq!(response.access_token, "proxy-access-token");
|
||||
assert_eq!(
|
||||
response.refresh_token.as_deref(),
|
||||
Some("proxy-refresh-token")
|
||||
);
|
||||
assert_eq!(response.expires_in, Some(7200));
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization.as_deref(),
|
||||
Some("Bearer shared-oauth-proxy-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code").map(String::as_str),
|
||||
Some("auth-code-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("redirect_uri").map(String::as_str),
|
||||
Some("https://oauth.example.com/oauth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("token_url").map(String::as_str),
|
||||
Some("https://oauth2.googleapis.com/token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_id").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_secret").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_SECRET)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0]
|
||||
.form
|
||||
.get("access_token_field")
|
||||
.map(String::as_str),
|
||||
Some("access_token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code_verifier").map(String::as_str),
|
||||
Some("code-verifier-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("resource").map(String::as_str),
|
||||
Some("https://mcp.notion.com")
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
||||
let server = MockProxyServer::start().await;
|
||||
@@ -1666,54 +1535,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var(
|
||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||
Some("shared-proxy-secret"),
|
||||
);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("shared-proxy-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("gateway-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("gateway-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||
|
||||
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_instance_prefix_with_colon() {
|
||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||
|
||||
@@ -192,9 +192,6 @@ pub struct JobContext {
|
||||
/// but subsequent tools (e.g., `json`) may need the full output. This
|
||||
/// stash stores the complete, unsanitized output so tools can reference
|
||||
/// previous results by ID via `$tool_call_id` parameter syntax.
|
||||
///
|
||||
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
|
||||
/// as `__routine_last_name` for fallback recovery in routine tool chains.
|
||||
#[serde(skip)]
|
||||
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
||||
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
|
||||
|
||||
+114
-20
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use libsql::params;
|
||||
use libsql::{params, params_from_iter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
@@ -471,25 +471,33 @@ impl RoutineStore for LibSqlBackend {
|
||||
}
|
||||
|
||||
let conn = self.connect().await?;
|
||||
|
||||
// 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 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"
|
||||
);
|
||||
let mut rows = conn
|
||||
.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![],
|
||||
)
|
||||
.query(&sql, params_from_iter(requested_ids))
|
||||
.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
|
||||
@@ -501,11 +509,9 @@ impl RoutineStore for LibSqlBackend {
|
||||
let id = Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
|
||||
|
||||
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);
|
||||
}
|
||||
let status_str: String = get_text(&row, 1);
|
||||
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
|
||||
statuses.insert(id, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,3 +600,91 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
+36
-396
@@ -403,10 +403,9 @@ pub struct ExtensionManager {
|
||||
/// when running in gateway mode, consumed by the web gateway's
|
||||
/// `/oauth/callback` handler.
|
||||
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
|
||||
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
||||
/// Resolved once at construction from `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`,
|
||||
/// then `GATEWAY_AUTH_TOKEN` as a backward-compatible fallback.
|
||||
oauth_proxy_auth_token: Option<String>,
|
||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
|
||||
gateway_token: Option<String>,
|
||||
/// Relay config captured at startup. Used by `auth_channel_relay` and
|
||||
/// `activate_channel_relay` instead of re-reading env vars.
|
||||
relay_config: Option<crate::config::RelayConfig>,
|
||||
@@ -536,7 +535,7 @@ impl ExtensionManager {
|
||||
activation_errors: RwLock::new(HashMap::new()),
|
||||
sse_manager: RwLock::new(None),
|
||||
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
||||
oauth_proxy_auth_token: crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
|
||||
relay_config: crate::config::RelayConfig::from_env(),
|
||||
relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)),
|
||||
@@ -660,66 +659,6 @@ impl ExtensionManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the relay URL override for an extension from settings.
|
||||
///
|
||||
/// Returns `Some(url)` if a non-empty per-extension `relay_url` override is
|
||||
/// set for the given extension; otherwise returns `None` and callers should
|
||||
/// fall back to the env-level `RelayConfig`.
|
||||
///
|
||||
/// Uses `self.user_id` (owner scope) for consistency with `configure()`,
|
||||
/// which also writes setting_path fields under the owner scope.
|
||||
///
|
||||
/// The override is validated: only `http` / `https` schemes are accepted
|
||||
/// and the URL must not contain userinfo (embedded credentials). This
|
||||
/// prevents a malicious override from exfiltrating the instance-wide relay
|
||||
/// API key to an attacker-controlled host.
|
||||
async fn effective_relay_url(&self, name: &str) -> Option<String> {
|
||||
if let Some(ref store) = self.store {
|
||||
let key = format!("extensions.{name}.relay_url");
|
||||
if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await {
|
||||
let url = v
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(ref u) = url {
|
||||
// Validate the override to prevent API-key exfiltration:
|
||||
// only allow http(s) with no embedded credentials.
|
||||
match url::Url::parse(u) {
|
||||
Ok(parsed)
|
||||
if (parsed.scheme() == "http" || parsed.scheme() == "https")
|
||||
&& parsed.username().is_empty()
|
||||
&& parsed.password().is_none() =>
|
||||
{
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url_host = %parsed.host_str().unwrap_or("unknown"),
|
||||
"effective_relay_url: using per-extension override from settings"
|
||||
);
|
||||
return url;
|
||||
}
|
||||
Ok(parsed) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
scheme = %parsed.scheme(),
|
||||
has_userinfo = !parsed.username().is_empty() || parsed.password().is_some(),
|
||||
"effective_relay_url: rejecting override — \
|
||||
only http/https without embedded credentials is allowed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"effective_relay_url: rejecting override — invalid URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the shared relay event sender for the webhook endpoint.
|
||||
pub fn relay_event_tx(
|
||||
&self,
|
||||
@@ -953,46 +892,6 @@ impl ExtensionManager {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether a stored `team_id` setting exists for the given relay extension.
|
||||
///
|
||||
/// Unlike [`is_relay_channel`], this does **not** consult the in-memory
|
||||
/// `installed_relay_extensions` set — it only looks at the persistent settings
|
||||
/// store. This distinction matters for `auth_channel_relay`: an extension can
|
||||
/// be *installed* (present in the in-memory set) but not yet *authenticated*
|
||||
/// (no OAuth completed, no team_id stored).
|
||||
async fn has_stored_team_id(&self, name: &str, _user_id: &str) -> bool {
|
||||
if let Some(ref store) = self.store {
|
||||
let key = format!("relay:{}:team_id", name);
|
||||
// Use owner scope (self.user_id) for consistency: the OAuth callback
|
||||
// stores team_id under state.owner_id which maps to self.user_id.
|
||||
match store.get_setting(&self.user_id, &key).await {
|
||||
Ok(Some(v)) => {
|
||||
let has_id = v.as_str().is_some_and(|s| !s.is_empty());
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
has_team_id = has_id,
|
||||
"has_stored_team_id: checked store"
|
||||
);
|
||||
return has_id;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"has_stored_team_id: no team_id setting found"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"has_stored_team_id: failed to read from settings store"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Restore persisted relay channels after startup.
|
||||
///
|
||||
/// Loads the persisted active channel list, filters to relay types (those with
|
||||
@@ -1519,7 +1418,7 @@ impl ExtensionManager {
|
||||
let errors = self.activation_errors.read().await;
|
||||
for name in installed.iter() {
|
||||
let active = active_names.contains(name);
|
||||
let authenticated = self.has_stored_team_id(name, user_id).await;
|
||||
let authenticated = self.is_relay_channel(name, user_id).await;
|
||||
let activation_error = errors.get(name).cloned();
|
||||
let registry_entry = self
|
||||
.registry
|
||||
@@ -2789,7 +2688,7 @@ impl ExtensionManager {
|
||||
user_id: user_id.to_string(),
|
||||
secrets: Arc::clone(&self.secrets),
|
||||
sse_manager: self.sse_manager.read().await.clone(),
|
||||
gateway_token: self.oauth_proxy_auth_token.clone(),
|
||||
gateway_token: self.gateway_token.clone(),
|
||||
token_exchange_extra_params,
|
||||
client_id_secret_name: if server.oauth.is_none() {
|
||||
Some(server.client_id_secret_name())
|
||||
@@ -3306,7 +3205,7 @@ impl ExtensionManager {
|
||||
user_id: user_id.to_string(),
|
||||
secrets: Arc::clone(&self.secrets),
|
||||
sse_manager: self.sse_manager.read().await.clone(),
|
||||
gateway_token: self.oauth_proxy_auth_token.clone(),
|
||||
gateway_token: self.gateway_token.clone(),
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
client_id_secret_name: None,
|
||||
created_at: std::time::Instant::now(),
|
||||
@@ -4292,69 +4191,20 @@ impl ExtensionManager {
|
||||
name: &str,
|
||||
user_id: &str,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
user_id = %user_id,
|
||||
"auth_channel_relay: starting"
|
||||
);
|
||||
|
||||
// Check if already authenticated by looking for a stored team_id.
|
||||
// We intentionally skip the `installed_relay_extensions` in-memory set
|
||||
// here because that set only tracks *installed* extensions — an extension
|
||||
// can be installed (via registry) but not yet authenticated (no OAuth
|
||||
// completed). Checking just `is_relay_channel()` would short-circuit
|
||||
// to "authenticated" even when no team_id exists, preventing the OAuth
|
||||
// flow from being offered to the user.
|
||||
if self.has_stored_team_id(name, user_id).await {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: already authenticated (team_id in store)"
|
||||
);
|
||||
// Check if already authenticated (team_id setting exists)
|
||||
if self.is_relay_channel(name, user_id).await {
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::ChannelRelay));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: no stored team_id, initiating OAuth"
|
||||
);
|
||||
|
||||
// Use relay config captured at startup
|
||||
let relay_config = self.relay_config().map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"auth_channel_relay: relay config not available — \
|
||||
CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Allow per-extension URL override from settings
|
||||
let effective_url = self
|
||||
.effective_relay_url(name)
|
||||
.await
|
||||
.unwrap_or_else(|| relay_config.url.clone());
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"auth_channel_relay: creating relay client for OAuth"
|
||||
);
|
||||
let relay_config = self.relay_config()?;
|
||||
|
||||
let client = crate::channels::relay::RelayClient::new(
|
||||
effective_url.clone(),
|
||||
relay_config.url.clone(),
|
||||
relay_config.api_key.clone(),
|
||||
relay_config.request_timeout_secs,
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"auth_channel_relay: failed to create relay HTTP client"
|
||||
);
|
||||
ExtensionError::Config(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||
|
||||
// Generate CSRF nonce — IronClaw validates this on the callback to ensure
|
||||
// the OAuth completion is legitimate. Channel-relay embeds it in the signed
|
||||
@@ -4366,44 +4216,18 @@ impl ExtensionManager {
|
||||
self.secrets
|
||||
.create(user_id, CreateSecretParams::new(&state_key, &state_nonce))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"auth_channel_relay: failed to store OAuth state nonce"
|
||||
);
|
||||
ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}"))
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?;
|
||||
|
||||
// Channel-relay derives all URLs from trusted instance_url in chat-api.
|
||||
// We only pass the nonce for CSRF validation on the callback.
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"auth_channel_relay: calling initiate_oauth on channel-relay"
|
||||
);
|
||||
match client.initiate_oauth(Some(&state_nonce)).await {
|
||||
Ok(auth_url) => {
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
"auth_channel_relay: OAuth URL obtained, awaiting user authorization"
|
||||
);
|
||||
Ok(AuthResult::awaiting_authorization(
|
||||
name,
|
||||
ExtensionKind::ChannelRelay,
|
||||
auth_url,
|
||||
"redirect".to_string(),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"auth_channel_relay: initiate_oauth call to channel-relay failed"
|
||||
);
|
||||
Err(ExtensionError::AuthFailed(e.to_string()))
|
||||
}
|
||||
Ok(auth_url) => Ok(AuthResult::awaiting_authorization(
|
||||
name,
|
||||
ExtensionKind::ChannelRelay,
|
||||
auth_url,
|
||||
"redirect".to_string(),
|
||||
)),
|
||||
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4413,112 +4237,40 @@ impl ExtensionManager {
|
||||
name: &str,
|
||||
user_id: &str,
|
||||
) -> Result<ActivateResult, ExtensionError> {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
user_id = %user_id,
|
||||
"activate_channel_relay: starting"
|
||||
);
|
||||
|
||||
let team_id_key = format!("relay:{}:team_id", name);
|
||||
|
||||
// Get team_id from settings (stored by the OAuth callback)
|
||||
let team_id = if let Some(ref store) = self.store {
|
||||
match store.get_setting(user_id, &team_id_key).await {
|
||||
Ok(Some(v)) => {
|
||||
let id = v.as_str().map(|s| s.to_string()).unwrap_or_default();
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
team_id_empty = id.is_empty(),
|
||||
"activate_channel_relay: loaded team_id from store"
|
||||
);
|
||||
id
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
setting_key = %team_id_key,
|
||||
"activate_channel_relay: no team_id in settings store"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to read team_id from settings store"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
store
|
||||
.get_setting(user_id, &team_id_key)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: no settings store available"
|
||||
);
|
||||
String::new()
|
||||
};
|
||||
|
||||
if team_id.is_empty() {
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: team_id is empty, returning AuthRequired"
|
||||
);
|
||||
return Err(ExtensionError::AuthRequired);
|
||||
}
|
||||
|
||||
// Use relay config captured at startup
|
||||
let relay_config = self.relay_config().map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: relay config not available"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Allow per-extension URL override from settings
|
||||
let effective_url = self
|
||||
.effective_relay_url(name)
|
||||
.await
|
||||
.unwrap_or_else(|| relay_config.url.clone());
|
||||
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"activate_channel_relay: relay config loaded"
|
||||
);
|
||||
let relay_config = self.relay_config()?;
|
||||
|
||||
let instance_id = self.relay_instance_id(relay_config, user_id);
|
||||
|
||||
let client = crate::channels::relay::RelayClient::new(
|
||||
effective_url.clone(),
|
||||
relay_config.url.clone(),
|
||||
relay_config.api_key.clone(),
|
||||
relay_config.request_timeout_secs,
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to create relay HTTP client"
|
||||
);
|
||||
ExtensionError::ActivationFailed(e.to_string())
|
||||
})?;
|
||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||
|
||||
// Fetch the per-instance signing secret from channel-relay.
|
||||
// This must succeed — there is no fallback.
|
||||
tracing::debug!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
"activate_channel_relay: fetching signing secret from channel-relay"
|
||||
);
|
||||
let signing_secret = client.get_signing_secret(&team_id).await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
relay_url = %effective_url,
|
||||
error = %e,
|
||||
"activate_channel_relay: failed to fetch signing secret from channel-relay"
|
||||
);
|
||||
ExtensionError::Config(format!("Failed to fetch relay signing secret: {e}"))
|
||||
})?;
|
||||
|
||||
@@ -4537,29 +4289,16 @@ impl ExtensionManager {
|
||||
// Hot-add to channel manager
|
||||
let cm_guard = self.relay_channel_manager.read().await;
|
||||
let channel_mgr = cm_guard.as_ref().ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: channel manager not initialized"
|
||||
);
|
||||
ExtensionError::ActivationFailed("Channel manager not initialized".to_string())
|
||||
})?;
|
||||
|
||||
channel_mgr.hot_add(Box::new(channel)).await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
error = %e,
|
||||
"activate_channel_relay: hot_add to channel manager failed"
|
||||
);
|
||||
ExtensionError::ActivationFailed(e.to_string())
|
||||
})?;
|
||||
channel_mgr
|
||||
.hot_add(Box::new(channel))
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||
|
||||
if let Ok(mut cache) = self.relay_signing_secret_cache.lock() {
|
||||
*cache = Some(signing_secret);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
"activate_channel_relay: failed to cache signing secret (mutex poisoned)"
|
||||
);
|
||||
}
|
||||
|
||||
// Store the event sender so the web gateway's relay webhook endpoint can push events
|
||||
@@ -4577,12 +4316,6 @@ impl ExtensionManager {
|
||||
self.broadcast_extension_status(name, "active", Some(&status_msg))
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
extension = %name,
|
||||
instance_id = %instance_id,
|
||||
"activate_channel_relay: relay channel activated successfully"
|
||||
);
|
||||
|
||||
Ok(ActivateResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::ChannelRelay,
|
||||
@@ -4862,41 +4595,6 @@ impl ExtensionManager {
|
||||
}
|
||||
Ok(ExtensionSetupSchema { secrets, fields })
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {
|
||||
let relay_url_key = format!("extensions.{name}.relay_url");
|
||||
let current_url = if let Some(ref store) = self.store {
|
||||
match store.get_setting(&self.user_id, &relay_url_key).await {
|
||||
Ok(value_opt) => value_opt
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
extension = %name,
|
||||
setting_key = %relay_url_key,
|
||||
error = %e,
|
||||
"get_setup_schema: failed to read relay_url from settings"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let env_url = self.relay_config.as_ref().map(|c| c.url.as_str());
|
||||
Ok(ExtensionSetupSchema {
|
||||
secrets: Vec::new(),
|
||||
fields: vec![crate::channels::web::types::SetupFieldInfo {
|
||||
name: "relay_url".to_string(),
|
||||
prompt: format!(
|
||||
"Channel-relay service URL (leave empty to use env default{})",
|
||||
env_url.map(|u| format!(": {u}")).unwrap_or_default()
|
||||
),
|
||||
optional: true,
|
||||
provided: current_url.is_some(),
|
||||
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
|
||||
}],
|
||||
})
|
||||
}
|
||||
_ => Ok(ExtensionSetupSchema {
|
||||
secrets: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
@@ -5299,17 +4997,7 @@ impl ExtensionManager {
|
||||
names.insert(server.token_secret_name());
|
||||
(names, Vec::new())
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {
|
||||
let relay_fields = vec![crate::tools::wasm::ToolFieldSetupSchema {
|
||||
name: "relay_url".to_string(),
|
||||
prompt: "Channel-relay service URL override".to_string(),
|
||||
optional: true,
|
||||
setting_path: Some(format!("extensions.{name}.relay_url")),
|
||||
input_type: crate::tools::wasm::ToolSetupFieldInputType::Text,
|
||||
restart_required: false,
|
||||
}];
|
||||
(std::collections::HashSet::new(), relay_fields)
|
||||
}
|
||||
ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let allowed_fields: std::collections::HashSet<String> =
|
||||
@@ -5400,28 +5088,13 @@ impl ExtensionManager {
|
||||
)));
|
||||
}
|
||||
let trimmed = field_value.trim();
|
||||
let field_def = setup_field_defs.get(field_name);
|
||||
|
||||
// Empty value on an optional field with a setting_path: clear the
|
||||
// stored override so the system reverts to the env/default value.
|
||||
if trimmed.is_empty() {
|
||||
if let Some(def) = field_def
|
||||
&& def.optional
|
||||
{
|
||||
stored_fields.remove(field_name);
|
||||
if let Some(setting_path) = &def.setting_path {
|
||||
Self::validate_setup_setting_path(name, setting_path)?;
|
||||
if let Some(store) = self.store.as_ref() {
|
||||
let _ = store.delete_setting(&self.user_id, setting_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
stored_fields.insert(field_name.clone(), trimmed.to_string());
|
||||
|
||||
if let Some(field_def) = field_def {
|
||||
if let Some(field_def) = setup_field_defs.get(field_name) {
|
||||
if field_def.restart_required {
|
||||
restart_required = true;
|
||||
}
|
||||
@@ -7385,39 +7058,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: installed-but-not-authenticated relay must NOT short-circuit
|
||||
/// `auth_channel_relay()` to "authenticated". Previously, `auth_channel_relay`
|
||||
/// called `is_relay_channel()` which checked the in-memory
|
||||
/// `installed_relay_extensions` set; that returned `true` even when no team_id
|
||||
/// existed in the store, so the OAuth URL was never offered.
|
||||
#[tokio::test]
|
||||
async fn test_auth_channel_relay_installed_without_team_id_is_not_authenticated() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let mgr = make_test_manager(None, dir.path().to_path_buf());
|
||||
|
||||
// Mark as installed (simulates clicking Install in the UI)
|
||||
mgr.installed_relay_extensions
|
||||
.write()
|
||||
.await
|
||||
.insert("slack-relay".to_string());
|
||||
|
||||
// Without a stored team_id, auth should NOT return authenticated.
|
||||
// It should fail because relay config is missing (no CHANNEL_RELAY_URL),
|
||||
// but the key assertion is that it does NOT return Ok(authenticated).
|
||||
let result = mgr.auth_channel_relay("slack-relay", "test").await;
|
||||
match result {
|
||||
Ok(ref auth_result) if auth_result.is_authenticated() => {
|
||||
panic!(
|
||||
"auth_channel_relay returned authenticated for installed-but-no-team-id relay; \
|
||||
expected either an OAuth URL or a config error"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Config error (no relay URL) or awaiting_authorization — both are correct
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_relay_shuts_down_via_relay_channel_manager() {
|
||||
// Regression: remove() only checked channel_runtime for shutdown, missing
|
||||
|
||||
@@ -20,7 +20,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||
normalize_cron_expression,
|
||||
normalize_cron_expression, reset_routine_verification_state, routine_verification_fingerprint,
|
||||
routine_verification_status,
|
||||
};
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
@@ -414,12 +415,29 @@ 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",
|
||||
@@ -650,23 +668,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
|
||||
|
||||
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
|
||||
ctx.tool_output_stash
|
||||
.write()
|
||||
.await
|
||||
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
|
||||
}
|
||||
|
||||
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
|
||||
ctx.tool_output_stash
|
||||
.read()
|
||||
.await
|
||||
.get(ROUTINE_LAST_NAME_STASH_KEY)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
|
||||
params.get(field).and_then(Value::as_object)
|
||||
}
|
||||
@@ -1080,7 +1081,8 @@ 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."
|
||||
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."
|
||||
}
|
||||
|
||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||
@@ -1110,7 +1112,6 @@ impl Tool for RoutineCreateTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
let normalized = parse_routine_create_request(¶ms)?;
|
||||
stash_last_routine_name(ctx, &normalized.name).await;
|
||||
let trigger = build_routine_trigger(&normalized.trigger);
|
||||
let action =
|
||||
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
|
||||
@@ -1126,7 +1127,7 @@ impl Tool for RoutineCreateTool {
|
||||
None
|
||||
};
|
||||
|
||||
let routine = Routine {
|
||||
let mut routine = Routine {
|
||||
id: Uuid::new_v4(),
|
||||
name: normalized.name.clone(),
|
||||
description: normalized.description.clone(),
|
||||
@@ -1152,6 +1153,10 @@ 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)
|
||||
@@ -1166,12 +1171,14 @@ 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,
|
||||
"name": routine.name.clone(),
|
||||
"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()))
|
||||
@@ -1224,10 +1231,24 @@ 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,
|
||||
@@ -1239,6 +1260,8 @@ 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();
|
||||
@@ -1277,7 +1300,8 @@ 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."
|
||||
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."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -1292,7 +1316,6 @@ impl Tool for RoutineUpdateTool {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = require_str(¶ms, "name")?;
|
||||
stash_last_routine_name(ctx, name).await;
|
||||
|
||||
let mut routine = self
|
||||
.store
|
||||
@@ -1301,6 +1324,9 @@ 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;
|
||||
@@ -1312,8 +1338,18 @@ 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, .. } => *p = prompt.to_string(),
|
||||
RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(),
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1344,12 +1380,16 @@ 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.or(old_tz);
|
||||
let effective_tz = new_timezone.clone().or(old_tz.clone());
|
||||
// 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(),
|
||||
@@ -1363,6 +1403,12 @@ 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
|
||||
@@ -1371,12 +1417,14 @@ 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,
|
||||
"name": routine.name.clone(),
|
||||
"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()))
|
||||
@@ -1430,24 +1478,11 @@ impl Tool for RoutineDeleteTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
|
||||
if name.trim().is_empty() {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"'name' parameter cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
name.to_string()
|
||||
} else {
|
||||
restore_last_routine_name(ctx).await.ok_or_else(|| {
|
||||
ToolError::InvalidParameters(
|
||||
"missing 'name' parameter and no previous routine target to infer".to_string(),
|
||||
)
|
||||
})?
|
||||
};
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let routine = self
|
||||
.store
|
||||
.get_routine_by_name(&ctx.user_id, &name)
|
||||
.get_routine_by_name(&ctx.user_id, name)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
|
||||
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
|
||||
@@ -1462,7 +1497,7 @@ impl Tool for RoutineDeleteTool {
|
||||
self.engine.refresh_event_cache().await;
|
||||
|
||||
let result = serde_json::json!({
|
||||
"name": &name,
|
||||
"name": name,
|
||||
"deleted": deleted,
|
||||
});
|
||||
|
||||
|
||||
+1
-19
@@ -117,11 +117,6 @@ impl McpClient {
|
||||
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
|
||||
///
|
||||
/// Returns an error if the config uses a non-HTTP transport.
|
||||
///
|
||||
/// **Note:** The session manager is NOT wired into the transport. For
|
||||
/// production use, prefer `create_client_from_config()` which constructs
|
||||
/// the transport with session tracking.
|
||||
#[cfg(test)]
|
||||
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
|
||||
if !matches!(
|
||||
config.effective_transport(),
|
||||
@@ -219,14 +214,7 @@ impl McpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a session manager to the **client** only.
|
||||
///
|
||||
/// **Warning:** This does NOT wire the session manager into the underlying
|
||||
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
|
||||
/// from responses. For production use, construct the transport with
|
||||
/// `HttpMcpTransport::with_session_manager()` and pass it to
|
||||
/// `new_with_transport()` instead. See `create_client_from_config()`.
|
||||
#[cfg(test)]
|
||||
/// Attach a session manager for Streamable HTTP session tracking.
|
||||
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
|
||||
self.session_manager = Some(session_manager);
|
||||
self
|
||||
@@ -247,12 +235,6 @@ impl McpClient {
|
||||
self.session_manager.is_some()
|
||||
}
|
||||
|
||||
/// Get the underlying transport (test-only).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
|
||||
&self.transport
|
||||
}
|
||||
|
||||
/// Get the next request ID.
|
||||
fn next_request_id(&self) -> u64 {
|
||||
self.next_id.fetch_add(1, Ordering::SeqCst)
|
||||
|
||||
+16
-101
@@ -7,7 +7,6 @@ use std::sync::Arc;
|
||||
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
|
||||
use crate::tools::mcp::http_transport::HttpMcpTransport;
|
||||
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
|
||||
|
||||
/// Error returned when MCP client creation fails.
|
||||
@@ -79,37 +78,33 @@ pub async fn create_client_from_config(
|
||||
Err(McpFactoryError::UnixNotSupported { name: server_name })
|
||||
}
|
||||
EffectiveTransport::Http => {
|
||||
// Authenticated (OAuth) path: tokens exist or server requires auth.
|
||||
if let Some(ref secrets) = secrets {
|
||||
let has_tokens =
|
||||
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
|
||||
|
||||
if has_tokens || server.requires_auth() {
|
||||
return Ok(McpClient::new_authenticated(
|
||||
Ok(McpClient::new_authenticated(
|
||||
server,
|
||||
Arc::clone(session_manager),
|
||||
Arc::clone(secrets),
|
||||
user_id,
|
||||
));
|
||||
))
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server)
|
||||
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||
name: server_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.with_session_manager(Arc::clone(session_manager)))
|
||||
}
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server)
|
||||
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||
name: server_name,
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.with_session_manager(Arc::clone(session_manager)))
|
||||
}
|
||||
|
||||
// Non-OAuth HTTP: wire the session manager into the *transport* so
|
||||
// it captures `Mcp-Session-Id` from responses. Passing it only to
|
||||
// the client (via `with_session_manager`) is not enough — the
|
||||
// transport must know about it to read/write the header.
|
||||
let transport = Arc::new(
|
||||
HttpMcpTransport::new(server.url.clone(), server.name.clone())
|
||||
.with_session_manager(Arc::clone(session_manager)),
|
||||
);
|
||||
Ok(McpClient::new_with_transport(
|
||||
server.name.clone(),
|
||||
transport,
|
||||
Some(Arc::clone(session_manager)),
|
||||
secrets,
|
||||
user_id,
|
||||
Some(server),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,84 +134,4 @@ mod tests {
|
||||
"non-OAuth HTTP clients must carry a session manager"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: the factory must wire the session manager into the
|
||||
/// *transport*, not just the client. Otherwise the transport never
|
||||
/// captures `Mcp-Session-Id` from responses and subsequent requests
|
||||
/// lack the header, causing the server to reject them.
|
||||
#[tokio::test]
|
||||
async fn test_factory_non_oauth_http_transport_captures_session_id() {
|
||||
use axum::http::header::HeaderName;
|
||||
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const SESSION_ID: &str = "test-session-abc123";
|
||||
|
||||
async fn session_echo() -> impl IntoResponse {
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {}
|
||||
})
|
||||
.to_string();
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(
|
||||
HeaderName::from_static("mcp-session-id"),
|
||||
SESSION_ID.to_string(),
|
||||
)],
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
let app = Router::new().route("/", post(session_echo));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let server = McpServerConfig::new("session-test", &url);
|
||||
let session_manager = Arc::new(McpSessionManager::new());
|
||||
let process_manager = Arc::new(McpProcessManager::new());
|
||||
|
||||
let client = create_client_from_config(
|
||||
server,
|
||||
&session_manager,
|
||||
&process_manager,
|
||||
None,
|
||||
"test-user",
|
||||
)
|
||||
.await
|
||||
.expect("factory should succeed for HTTP config");
|
||||
|
||||
// Pre-create a session entry so that update_session_id has something to update.
|
||||
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
|
||||
session_manager.get_or_create("session-test", &url).await;
|
||||
|
||||
// Send a request through the client's transport to trigger session capture.
|
||||
use crate::tools::mcp::protocol::McpRequest;
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
method: "test".to_string(),
|
||||
params: Some(serde_json::json!({})),
|
||||
};
|
||||
let headers = std::collections::HashMap::new();
|
||||
client
|
||||
.transport()
|
||||
.send(&request, &headers)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
// Verify the session manager captured the session ID from the response.
|
||||
let captured = session_manager.get_session_id("session-test").await;
|
||||
assert_eq!(
|
||||
captured.as_deref(),
|
||||
Some(SESSION_ID),
|
||||
"transport must capture Mcp-Session-Id into session manager"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,34 +494,6 @@ mod tests {
|
||||
assert_eq!(echoed["authorization"], "Bearer oauth-token");
|
||||
}
|
||||
|
||||
/// Regression test for #1436: 202 Accepted responses for notifications
|
||||
/// were parsed as JSON, causing "Failed to parse MCP response" errors
|
||||
/// that broke the MCP session handshake.
|
||||
#[tokio::test]
|
||||
async fn test_wire_202_accepted_for_notification() {
|
||||
use axum::{Router, http::StatusCode, routing::post};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn accept_notification() -> StatusCode {
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
let app = Router::new().route("/", post(accept_notification));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let transport = HttpMcpTransport::new(&url, "test-202");
|
||||
let request = McpRequest::initialized_notification();
|
||||
let response = transport.send(&request, &HashMap::new()).await.unwrap();
|
||||
assert!(response.result.is_none());
|
||||
assert!(response.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
|
||||
let (url, _handle) = spawn_echo_server().await;
|
||||
|
||||
@@ -446,14 +446,16 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
|
||||
builtin.as_ref(),
|
||||
exchange_proxy_url.is_some(),
|
||||
);
|
||||
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
|
||||
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
Some(OAuthRefreshConfig {
|
||||
token_url: oauth.token_url.clone(),
|
||||
client_id,
|
||||
client_secret,
|
||||
exchange_proxy_url,
|
||||
gateway_token: oauth_proxy_auth_token,
|
||||
gateway_token,
|
||||
secret_name: auth.secret_name.clone(),
|
||||
provider: auth.provider.clone(),
|
||||
})
|
||||
@@ -889,11 +891,6 @@ mod tests {
|
||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||
};
|
||||
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
@@ -985,7 +982,6 @@ mod tests {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
|
||||
// google_oauth_token should fall back to built-in credentials
|
||||
let caps = CapabilitiesFile {
|
||||
@@ -1025,7 +1021,6 @@ mod tests {
|
||||
Some("https://compose-api.example.com"),
|
||||
);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _client_id_guard =
|
||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||
|
||||
@@ -1066,7 +1061,6 @@ mod tests {
|
||||
Some("https://compose-api.example.com"),
|
||||
);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _client_id_guard =
|
||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||
let _client_secret_guard =
|
||||
@@ -1101,47 +1095,6 @@ mod tests {
|
||||
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
|
||||
use crate::tools::wasm::capabilities_schema::{
|
||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||
};
|
||||
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var(
|
||||
"IRONCLAW_OAUTH_EXCHANGE_URL",
|
||||
Some("https://compose-api.example.com"),
|
||||
);
|
||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||
let _oauth_proxy_token_guard = set_env_var(
|
||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||
Some("shared-oauth-proxy-secret"),
|
||||
);
|
||||
let _client_id_guard =
|
||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
oauth: Some(OAuthConfigSchema {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
|
||||
assert_eq!(
|
||||
config.gateway_token.as_deref(),
|
||||
Some("shared-oauth-proxy-secret")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Security regression tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -62,8 +62,7 @@ pub struct OAuthRefreshConfig {
|
||||
pub client_secret: Option<String>,
|
||||
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
||||
pub exchange_proxy_url: Option<String>,
|
||||
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
/// Gateway auth token for authenticating with the hosted OAuth proxy.
|
||||
pub gateway_token: Option<String>,
|
||||
/// Secret name of the access token (e.g., "google_oauth_token").
|
||||
/// The refresh token lives at `{secret_name}_refresh_token`.
|
||||
@@ -72,12 +71,6 @@ pub struct OAuthRefreshConfig {
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl OAuthRefreshConfig {
|
||||
fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
||||
self.gateway_token.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-resolved credential for host-based injection.
|
||||
///
|
||||
/// Built before each WASM execution by decrypting secrets from the store.
|
||||
@@ -1225,9 +1218,9 @@ async fn refresh_oauth_token(
|
||||
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
||||
|
||||
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
||||
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
|
||||
let Some(gateway_token) = config.gateway_token.as_deref() else {
|
||||
tracing::warn!(
|
||||
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
|
||||
"OAuth refresh proxy is configured, but no gateway auth token is available"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
@@ -1242,7 +1235,7 @@ async fn refresh_oauth_token(
|
||||
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
||||
oauth_defaults::ProxyRefreshTokenRequest {
|
||||
proxy_url,
|
||||
gateway_token: oauth_proxy_auth_token,
|
||||
gateway_token,
|
||||
token_url: &config.token_url,
|
||||
client_id: &config.client_id,
|
||||
client_secret: config.client_secret.as_deref(),
|
||||
@@ -2711,8 +2704,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
|
||||
{
|
||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
|
||||
@@ -205,44 +205,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 5: routine_update_fail_delete_fallback
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_update_fail_delete_fallback() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
|
||||
))
|
||||
.expect("failed to load routine_update_fail_delete_fallback.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Try converting a routine trigger, then recover by deleting it")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
|
||||
"routine_update should fail in this regression path: {completed:?}"
|
||||
);
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
|
||||
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 6: routine_manual_create_defaults_to_tools_enabled
|
||||
// Test 5: routine_manual_create_defaults_to_tools_enabled
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
@@ -283,7 +246,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 7: routine_manual_create_explicit_no_tools
|
||||
// Test 6: routine_manual_create_explicit_no_tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
@@ -324,7 +287,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8: routine_history
|
||||
// Test 7: routine_history
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created the any-channel-bug-watcher routine for bug messages.",
|
||||
"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.",
|
||||
"input_tokens": 170,
|
||||
"output_tokens": 18
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created the telegram-bug-watcher routine for Telegram bug messages.",
|
||||
"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.",
|
||||
"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 pre-authorized.",
|
||||
"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.",
|
||||
"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 active routines.",
|
||||
"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.",
|
||||
"input_tokens": 250,
|
||||
"output_tokens": 24
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "I created a daily-check routine that runs at 9 AM every day. The routine list shows it as active.",
|
||||
"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.",
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 25
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "The history-test routine was created. Its run history is empty since it hasn't been triggered yet.",
|
||||
"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.",
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 25
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created the manual-triage routine. It will only run when explicitly fired.",
|
||||
"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.",
|
||||
"input_tokens": 140,
|
||||
"output_tokens": 18
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
|
||||
"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.",
|
||||
"input_tokens": 140,
|
||||
"output_tokens": 18
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created, updated, and then deleted the temp-routine successfully.",
|
||||
"content": "Created, updated, and then deleted the temp-routine successfully. The update would have left it unverified until it was tested again.",
|
||||
"input_tokens": 400,
|
||||
"output_tokens": 20
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"model_name": "test-routine-update-fail-delete-fallback",
|
||||
"expects": {
|
||||
"tools_used": ["routine_create", "routine_update", "routine_delete"],
|
||||
"tool_results_contain": {
|
||||
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
|
||||
"routine_delete": "temp-routine"
|
||||
},
|
||||
"min_responses": 1
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_rc_fallback",
|
||||
"name": "routine_create",
|
||||
"arguments": {
|
||||
"name": "temp-routine",
|
||||
"trigger_type": "manual",
|
||||
"prompt": "Temporary routine for fallback test."
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 120,
|
||||
"output_tokens": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_ru_fallback",
|
||||
"name": "routine_update",
|
||||
"arguments": {
|
||||
"name": "temp-routine",
|
||||
"schedule": "0 */10 * * * *"
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 200,
|
||||
"output_tokens": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_rd_fallback",
|
||||
"name": "routine_delete",
|
||||
"arguments": {}
|
||||
}
|
||||
],
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "I recovered from the failed update and cleaned up the original routine.",
|
||||
"input_tokens": 380,
|
||||
"output_tokens": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod tests {
|
||||
use chrono::Utc;
|
||||
use ironclaw::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
reset_routine_verification_state, routine_verification_fingerprint,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -338,4 +339,115 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user