mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Merge pull request #1467 from nearai/staging-promote/ee6f5cd6-23354122351
chore: promote staging to staging-promote/3da9810e-23351687636 (2026-03-20 17:14 UTC)
This commit is contained in:
@@ -17,7 +17,7 @@ use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
|
||||
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
||||
use crate::agent::session_manager::SessionManager;
|
||||
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
|
||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
|
||||
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
|
||||
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
|
||||
use crate::context::ContextManager;
|
||||
@@ -227,9 +227,12 @@ impl Agent {
|
||||
context_manager.clone(),
|
||||
deps.llm.clone(),
|
||||
deps.safety.clone(),
|
||||
deps.tools.clone(),
|
||||
deps.store.clone(),
|
||||
deps.hooks.clone(),
|
||||
SchedulerDeps {
|
||||
tools: deps.tools.clone(),
|
||||
extension_manager: deps.extension_manager.clone(),
|
||||
store: deps.store.clone(),
|
||||
hooks: deps.hooks.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(ref tx) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(tx.clone());
|
||||
@@ -600,6 +603,7 @@ impl Agent {
|
||||
Arc::clone(workspace),
|
||||
notify_tx,
|
||||
Some(self.scheduler.clone()),
|
||||
self.deps.extension_manager.clone(),
|
||||
self.tools().clone(),
|
||||
self.safety().clone(),
|
||||
self.deps.sandbox_readiness,
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_hea
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::{RoutineEngine, SandboxReadiness};
|
||||
pub use scheduler::Scheduler;
|
||||
pub use scheduler::{Scheduler, SchedulerDeps};
|
||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||
pub use session_manager::SessionManager;
|
||||
|
||||
+20
-233
@@ -17,7 +17,7 @@
|
||||
//! └──────────────┘
|
||||
//! ```
|
||||
|
||||
use std::collections::{HashSet, hash_map::DefaultHasher};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
@@ -28,171 +28,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::RoutineError;
|
||||
|
||||
pub const FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY: &str = "routines.full_job_owner_allowed_tools";
|
||||
pub const FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY: &str =
|
||||
"routines.full_job_default_permission_mode";
|
||||
|
||||
/// Persisted per-routine permission mode for autonomous `full_job` routines.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FullJobPermissionMode {
|
||||
/// Only use the routine's stored `tool_permissions`.
|
||||
#[default]
|
||||
Explicit,
|
||||
/// Union the owner-scoped allowlist with the routine's `tool_permissions`.
|
||||
InheritOwner,
|
||||
}
|
||||
|
||||
impl FullJobPermissionMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Explicit => "explicit",
|
||||
Self::InheritOwner => "inherit_owner",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FullJobPermissionMode {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"explicit" => Ok(Self::Explicit),
|
||||
"inherit_owner" => Ok(Self::InheritOwner),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner-scoped default behavior for newly-created `full_job` routines.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FullJobPermissionDefaultMode {
|
||||
Explicit,
|
||||
#[default]
|
||||
InheritOwner,
|
||||
CopyOwner,
|
||||
}
|
||||
|
||||
impl FullJobPermissionDefaultMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Explicit => "explicit",
|
||||
Self::InheritOwner => "inherit_owner",
|
||||
Self::CopyOwner => "copy_owner",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FullJobPermissionDefaultMode {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"explicit" => Ok(Self::Explicit),
|
||||
"inherit_owner" => Ok(Self::InheritOwner),
|
||||
"copy_owner" => Ok(Self::CopyOwner),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct FullJobPermissionSettings {
|
||||
pub owner_allowed_tools: Vec<String>,
|
||||
pub default_mode: FullJobPermissionDefaultMode,
|
||||
}
|
||||
|
||||
pub fn normalize_tool_names<I>(tools: I) -> Vec<String>
|
||||
where
|
||||
I: IntoIterator<Item = String>,
|
||||
{
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tools {
|
||||
let trimmed = tool.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_name = trimmed.to_string();
|
||||
if seen.insert(normalized_name.clone()) {
|
||||
normalized.push(normalized_name);
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub fn parse_full_job_permission_mode(value: &serde_json::Value) -> FullJobPermissionMode {
|
||||
value
|
||||
.get("permission_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|mode| FullJobPermissionMode::from_str(mode).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_owner_allowed_tools_setting(value: Option<serde_json::Value>) -> Vec<String> {
|
||||
match value {
|
||||
Some(serde_json::Value::Array(values)) => normalize_tool_names(
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| value.as_str().map(ToOwned::to_owned)),
|
||||
),
|
||||
Some(serde_json::Value::String(csv)) => normalize_tool_names(
|
||||
csv.split([',', '\n'])
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_default_permission_mode_setting(
|
||||
value: Option<serde_json::Value>,
|
||||
) -> FullJobPermissionDefaultMode {
|
||||
value
|
||||
.and_then(|v| v.as_str().map(ToOwned::to_owned))
|
||||
.and_then(|mode| FullJobPermissionDefaultMode::from_str(&mode).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn load_full_job_permission_settings(
|
||||
store: &(dyn crate::db::SettingsStore + Sync),
|
||||
user_id: &str,
|
||||
) -> Result<FullJobPermissionSettings, crate::error::DatabaseError> {
|
||||
let owner_allowed_tools = parse_owner_allowed_tools_setting(
|
||||
store
|
||||
.get_setting(user_id, FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY)
|
||||
.await?,
|
||||
);
|
||||
let default_mode = parse_default_permission_mode_setting(
|
||||
store
|
||||
.get_setting(user_id, FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY)
|
||||
.await?,
|
||||
);
|
||||
Ok(FullJobPermissionSettings {
|
||||
owner_allowed_tools,
|
||||
default_mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn effective_full_job_tool_permissions(
|
||||
permission_mode: FullJobPermissionMode,
|
||||
routine_tool_permissions: &[String],
|
||||
owner_allowed_tools: &[String],
|
||||
) -> Vec<String> {
|
||||
match permission_mode {
|
||||
FullJobPermissionMode::Explicit => {
|
||||
normalize_tool_names(routine_tool_permissions.iter().cloned())
|
||||
}
|
||||
FullJobPermissionMode::InheritOwner => normalize_tool_names(
|
||||
owner_allowed_tools
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(routine_tool_permissions.iter().cloned()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Routine {
|
||||
@@ -400,15 +235,6 @@ pub enum RoutineAction {
|
||||
/// Max reasoning iterations (default: 10).
|
||||
#[serde(default = "default_max_iterations")]
|
||||
max_iterations: u32,
|
||||
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
|
||||
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
|
||||
/// automatically permitted in routine jobs without listing them here.
|
||||
#[serde(default)]
|
||||
tool_permissions: Vec<String>,
|
||||
/// Whether this routine should inherit the owner's durable full-job
|
||||
/// permission allowlist or use only its explicit `tool_permissions`.
|
||||
#[serde(default)]
|
||||
permission_mode: FullJobPermissionMode,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -433,18 +259,6 @@ fn clamp_max_tool_rounds(value: u64) -> u32 {
|
||||
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
|
||||
}
|
||||
|
||||
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||
normalize_tool_names(
|
||||
value
|
||||
.get("tool_permissions")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(String::from)),
|
||||
)
|
||||
}
|
||||
|
||||
impl RoutineAction {
|
||||
/// The string tag stored in the DB action_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
@@ -519,14 +333,10 @@ impl RoutineAction {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_iterations() as u64)
|
||||
as u32;
|
||||
let tool_permissions = parse_tool_permissions(&config);
|
||||
let permission_mode = parse_full_job_permission_mode(&config);
|
||||
Ok(RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
@@ -555,14 +365,10 @@ impl RoutineAction {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
} => serde_json::json!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"max_iterations": max_iterations,
|
||||
"tool_permissions": tool_permissions,
|
||||
"permission_mode": permission_mode,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -896,9 +702,8 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
FullJobPermissionMode, MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus,
|
||||
Trigger, content_hash, describe_cron, effective_full_job_tool_permissions, next_cron_fire,
|
||||
normalize_cron_expression,
|
||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||
describe_cron, next_cron_fire, normalize_cron_expression,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -965,68 +770,50 @@ mod tests {
|
||||
title: "Deploy review".to_string(),
|
||||
description: "Review and deploy pending changes".to_string(),
|
||||
max_iterations: 5,
|
||||
tool_permissions: vec!["shell".to_string()],
|
||||
permission_mode: FullJobPermissionMode::InheritOwner,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, permission_mode, .. }
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||
if title == "Deploy review"
|
||||
&& max_iterations == 5
|
||||
&& tool_permissions == vec!["shell".to_string()]
|
||||
&& permission_mode == FullJobPermissionMode::InheritOwner)
|
||||
&& max_iterations == 5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_full_job_missing_permission_mode_defaults_to_explicit() {
|
||||
fn test_action_full_job_ignores_legacy_permission_fields() {
|
||||
let parsed = RoutineAction::from_db(
|
||||
"full_job",
|
||||
serde_json::json!({
|
||||
"title": "Deploy review",
|
||||
"description": "Review and deploy pending changes",
|
||||
"max_iterations": 5,
|
||||
"tool_permissions": ["shell"]
|
||||
"tool_permissions": ["shell"],
|
||||
"permission_mode": "inherit_owner"
|
||||
}),
|
||||
)
|
||||
.expect("parse full_job");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
RoutineAction::FullJob {
|
||||
permission_mode: FullJobPermissionMode::Explicit,
|
||||
ref title,
|
||||
ref description,
|
||||
max_iterations,
|
||||
..
|
||||
}
|
||||
} if title == "Deploy review"
|
||||
&& description == "Review and deploy pending changes"
|
||||
&& max_iterations == 5
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_full_job_tool_permissions_inherit_owner_unions_lists() {
|
||||
let resolved = effective_full_job_tool_permissions(
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
&["shell".to_string(), "message".to_string()],
|
||||
&["message".to_string(), "http".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
vec![
|
||||
"message".to_string(),
|
||||
"http".to_string(),
|
||||
"shell".to_string()
|
||||
]
|
||||
parsed.to_config_json(),
|
||||
serde_json::json!({
|
||||
"title": "Deploy review",
|
||||
"description": "Review and deploy pending changes",
|
||||
"max_iterations": 5,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_full_job_tool_permissions_explicit_ignores_owner_defaults() {
|
||||
let resolved = effective_full_job_tool_permissions(
|
||||
FullJobPermissionMode::Explicit,
|
||||
&["shell".to_string()],
|
||||
&["message".to_string(), "http".to_string()],
|
||||
);
|
||||
assert_eq!(resolved, vec!["shell".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_status_display_parse() {
|
||||
for status in [
|
||||
|
||||
+29
-80
@@ -22,19 +22,20 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::Scheduler;
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
|
||||
effective_full_job_tool_permissions, load_full_job_permission_settings, next_cron_fire,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||
};
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::config::RoutineConfig;
|
||||
use crate::context::{JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::tools::{
|
||||
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
|
||||
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
|
||||
prepare_tool_params,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
use ironclaw_safety::SafetyLayer;
|
||||
@@ -69,6 +70,8 @@ pub struct RoutineEngine {
|
||||
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
|
||||
/// Scheduler for dispatching jobs (FullJob mode).
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
/// Owner-scoped extension activation state for autonomous tool resolution.
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
/// Tool registry for lightweight routine tool execution.
|
||||
tools: Arc<ToolRegistry>,
|
||||
/// Safety layer for tool output sanitization.
|
||||
@@ -90,6 +93,7 @@ impl RoutineEngine {
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
sandbox_readiness: SandboxReadiness,
|
||||
@@ -103,6 +107,7 @@ impl RoutineEngine {
|
||||
running_count: Arc::new(AtomicUsize::new(0)),
|
||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
scheduler,
|
||||
extension_manager,
|
||||
tools,
|
||||
safety,
|
||||
sandbox_readiness,
|
||||
@@ -702,6 +707,7 @@ impl RoutineEngine {
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
extension_manager: self.extension_manager.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
sandbox_readiness: self.sandbox_readiness,
|
||||
@@ -738,6 +744,7 @@ impl RoutineEngine {
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
extension_manager: self.extension_manager.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
sandbox_readiness: self.sandbox_readiness,
|
||||
@@ -875,6 +882,7 @@ struct EngineContext {
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
running_count: Arc<AtomicUsize>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
sandbox_readiness: SandboxReadiness,
|
||||
@@ -908,15 +916,11 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
} => {
|
||||
let execution = FullJobExecutionConfig {
|
||||
title,
|
||||
description,
|
||||
max_iterations: *max_iterations,
|
||||
tool_permissions,
|
||||
permission_mode: *permission_mode,
|
||||
};
|
||||
execute_full_job(&ctx, &routine, &run, &execution).await
|
||||
}
|
||||
@@ -1048,8 +1052,6 @@ struct FullJobExecutionConfig<'a> {
|
||||
title: &'a str,
|
||||
description: &'a str,
|
||||
max_iterations: u32,
|
||||
tool_permissions: &'a [String],
|
||||
permission_mode: crate::agent::routine::FullJobPermissionMode,
|
||||
}
|
||||
|
||||
async fn execute_full_job(
|
||||
@@ -1094,40 +1096,12 @@ async fn execute_full_job(
|
||||
}
|
||||
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
|
||||
|
||||
let effective_permissions = match execution.permission_mode {
|
||||
crate::agent::routine::FullJobPermissionMode::Explicit => {
|
||||
effective_full_job_tool_permissions(
|
||||
execution.permission_mode,
|
||||
execution.tool_permissions,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
crate::agent::routine::FullJobPermissionMode::InheritOwner => {
|
||||
let owner_permissions =
|
||||
load_full_job_permission_settings(ctx.store.as_ref(), &routine.user_id)
|
||||
.await
|
||||
.map_err(|e| RoutineError::Database {
|
||||
reason: format!("failed to load routine permission settings: {e}"),
|
||||
})?;
|
||||
effective_full_job_tool_permissions(
|
||||
execution.permission_mode,
|
||||
execution.tool_permissions,
|
||||
&owner_permissions.owner_allowed_tools,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
|
||||
// Always tools require explicit listing in the resolved effective permissions.
|
||||
let approval_context = ApprovalContext::autonomous_with_tools(effective_permissions);
|
||||
|
||||
let job_id = scheduler
|
||||
.dispatch_job_with_context(
|
||||
.dispatch_job(
|
||||
&routine.user_id,
|
||||
execution.title,
|
||||
execution.description,
|
||||
Some(metadata),
|
||||
approval_context,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| RoutineError::JobDispatchFailed {
|
||||
@@ -1416,6 +1390,9 @@ async fn execute_lightweight_with_tools(
|
||||
description: routine.name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let allowed_tools =
|
||||
autonomous_allowed_tool_names(&ctx.tools, ctx.extension_manager.as_ref(), &routine.user_id)
|
||||
.await;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
@@ -1450,8 +1427,11 @@ async fn execute_lightweight_with_tools(
|
||||
// Tool-enabled iteration
|
||||
let tool_defs = ctx
|
||||
.tools
|
||||
.tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
|
||||
.await;
|
||||
.tool_definitions()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|tool| allowed_tools.contains(&tool.name))
|
||||
.collect();
|
||||
|
||||
let request_messages = snapshot_messages_for_tool_iteration(&messages);
|
||||
let request = ToolCompletionRequest::new(request_messages, tool_defs)
|
||||
@@ -1486,7 +1466,7 @@ async fn execute_lightweight_with_tools(
|
||||
|
||||
// Execute tools sequentially
|
||||
for tc in response.tool_calls {
|
||||
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
|
||||
let result = execute_routine_tool(ctx, &job_ctx, &allowed_tools, &tc).await;
|
||||
|
||||
// Sanitize and wrap result (including errors)
|
||||
let result_content = match result {
|
||||
@@ -1555,31 +1535,16 @@ fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec<ChatMes
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Tools that must never be callable from lightweight routines.
|
||||
///
|
||||
/// These tools pose autonomy-escalation risks: a routine could self-replicate,
|
||||
/// modify its own triggers/prompts, delete other routines, or restart the agent.
|
||||
const ROUTINE_TOOL_DENYLIST: &[&str] = &[
|
||||
"routine_create",
|
||||
"routine_update",
|
||||
"routine_delete",
|
||||
"routine_fire",
|
||||
"restart",
|
||||
];
|
||||
|
||||
/// Execute a single tool for a lightweight routine.
|
||||
async fn execute_routine_tool(
|
||||
ctx: &EngineContext,
|
||||
job_ctx: &JobContext,
|
||||
allowed_tools: &std::collections::HashSet<String>,
|
||||
tc: &ToolCall,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Block tools that pose autonomy-escalation risks
|
||||
if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) {
|
||||
return Err(format!(
|
||||
"Tool '{}' is not available in lightweight routines",
|
||||
tc.name
|
||||
)
|
||||
.into());
|
||||
if !allowed_tools.contains(&tc.name) {
|
||||
let message = autonomous_unavailable_message(&tc.name, &job_ctx.user_id);
|
||||
return Err(message.into());
|
||||
}
|
||||
|
||||
// Check if tool exists
|
||||
@@ -1590,22 +1555,6 @@ async fn execute_routine_tool(
|
||||
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
|
||||
let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments);
|
||||
|
||||
// Check approval requirement: only allow Never tools in lightweight routines.
|
||||
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
|
||||
// Lightweight routines can be triggered by external events and may process untrusted data,
|
||||
// making them vulnerable to prompt injection that could trick the LLM into calling
|
||||
// sensitive tools. Blocking these tools entirely is the safest approach.
|
||||
match tool.requires_approval(&normalized_params) {
|
||||
ApprovalRequirement::Never => {}
|
||||
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
|
||||
return Err(format!(
|
||||
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
|
||||
tc.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = ctx
|
||||
.safety
|
||||
@@ -2021,8 +1970,8 @@ mod tests {
|
||||
];
|
||||
for tool in &denylisted {
|
||||
assert!(
|
||||
super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should be in ROUTINE_TOOL_DENYLIST",
|
||||
crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should be in AUTONOMOUS_TOOL_DENYLIST",
|
||||
tool
|
||||
);
|
||||
}
|
||||
@@ -2033,8 +1982,8 @@ mod tests {
|
||||
let allowed = vec!["echo", "time", "json", "http", "memory_search", "shell"];
|
||||
for tool in &allowed {
|
||||
assert!(
|
||||
!super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should NOT be in ROUTINE_TOOL_DENYLIST",
|
||||
!crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should NOT be in AUTONOMOUS_TOOL_DENYLIST",
|
||||
tool
|
||||
);
|
||||
}
|
||||
|
||||
+58
-19
@@ -14,10 +14,14 @@ use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params};
|
||||
use crate::tools::{
|
||||
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
||||
prepare_tool_params,
|
||||
};
|
||||
use crate::worker::job::{Worker, WorkerDeps};
|
||||
|
||||
/// Message to send to a worker.
|
||||
@@ -45,6 +49,14 @@ struct ScheduledSubtask {
|
||||
handle: JoinHandle<Result<TaskOutput, Error>>,
|
||||
}
|
||||
|
||||
/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
|
||||
pub struct SchedulerDeps {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
/// Schedules and manages parallel job execution.
|
||||
pub struct Scheduler {
|
||||
config: AgentConfig,
|
||||
@@ -52,6 +64,7 @@ pub struct Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
@@ -71,18 +84,17 @@ impl Scheduler {
|
||||
context_manager: Arc<ContextManager>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
deps: SchedulerDeps,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
context_manager,
|
||||
llm,
|
||||
safety,
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
tools: deps.tools,
|
||||
extension_manager: deps.extension_manager,
|
||||
store: deps.store,
|
||||
hooks: deps.hooks,
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -120,14 +132,21 @@ impl Scheduler {
|
||||
description: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
self.dispatch_job_inner(user_id, title, description, metadata, None)
|
||||
.await
|
||||
let approval_context = self.autonomous_approval_context(user_id).await;
|
||||
self.dispatch_job_inner(
|
||||
user_id,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
Some(approval_context),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dispatch a job with an explicit approval context for autonomous execution.
|
||||
///
|
||||
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
|
||||
/// to determine which tools are pre-approved (instead of blocking all non-`Never` tools).
|
||||
/// to determine the explicit autonomous allowlist for that job.
|
||||
pub async fn dispatch_job_with_context(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -216,6 +235,13 @@ impl Scheduler {
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext {
|
||||
ApprovalContext::autonomous_with_tools(
|
||||
autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
/// Schedule a job for execution.
|
||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||
self.schedule_with_context(job_id, None).await
|
||||
@@ -518,10 +544,7 @@ impl Scheduler {
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
return Err(crate::error::ToolError::AuthRequired {
|
||||
name: tool_name.to_string(),
|
||||
}
|
||||
.into());
|
||||
return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
|
||||
}
|
||||
|
||||
// Delegate to shared tool execution pipeline
|
||||
@@ -776,7 +799,18 @@ mod tests {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let hooks = Arc::new(HookRegistry::default());
|
||||
|
||||
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
|
||||
Scheduler::new(
|
||||
config,
|
||||
cm,
|
||||
llm,
|
||||
safety,
|
||||
SchedulerDeps {
|
||||
tools,
|
||||
extension_manager: None,
|
||||
store: None,
|
||||
hooks,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1003,12 +1037,14 @@ mod tests {
|
||||
async fn test_execute_tool_task_autonomous_unblocks_soft() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous context auto-approves UnlessAutoApproved
|
||||
// Autonomous execution only allows tools explicitly in scope.
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
cm.clone(),
|
||||
safety.clone(),
|
||||
Some(ApprovalContext::autonomous()),
|
||||
Some(ApprovalContext::autonomous_with_tools([
|
||||
"soft_gate".to_string()
|
||||
])),
|
||||
job_id,
|
||||
"soft_gate",
|
||||
serde_json::json!({}),
|
||||
@@ -1040,8 +1076,11 @@ mod tests {
|
||||
async fn test_execute_tool_task_autonomous_with_permissions() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous context with explicit permission for hard_gate
|
||||
let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]);
|
||||
// Autonomous context with explicit permission for both tools.
|
||||
let ctx = ApprovalContext::autonomous_with_tools([
|
||||
"soft_gate".to_string(),
|
||||
"hard_gate".to_string(),
|
||||
]);
|
||||
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
|
||||
@@ -10,29 +10,11 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
FullJobPermissionDefaultMode, FullJobPermissionMode, RoutineAction, Trigger,
|
||||
effective_full_job_tool_permissions, load_full_job_permission_settings, next_cron_fire,
|
||||
};
|
||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::error::RoutineError;
|
||||
|
||||
fn permission_mode_label(mode: FullJobPermissionMode) -> String {
|
||||
match mode {
|
||||
FullJobPermissionMode::Explicit => "explicit".to_string(),
|
||||
FullJobPermissionMode::InheritOwner => "inherit_owner".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_permission_mode_label(mode: FullJobPermissionDefaultMode) -> String {
|
||||
match mode {
|
||||
FullJobPermissionDefaultMode::Explicit => "explicit".to_string(),
|
||||
FullJobPermissionDefaultMode::InheritOwner => "inherit_owner".to_string(),
|
||||
FullJobPermissionDefaultMode::CopyOwner => "copy_owner".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn routines_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
||||
@@ -131,30 +113,6 @@ pub async fn routines_detail_handler(
|
||||
})
|
||||
.collect();
|
||||
let routine_info = RoutineInfo::from_routine(&routine);
|
||||
let full_job_permissions = match &routine.action {
|
||||
RoutineAction::FullJob {
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
let owner_settings =
|
||||
load_full_job_permission_settings(store.as_ref(), &routine.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Some(FullJobPermissionInfo {
|
||||
permission_mode: permission_mode_label(*permission_mode),
|
||||
default_permission_mode: default_permission_mode_label(owner_settings.default_mode),
|
||||
stored_tool_permissions: tool_permissions.clone(),
|
||||
effective_tool_permissions: effective_full_job_tool_permissions(
|
||||
*permission_mode,
|
||||
tool_permissions,
|
||||
&owner_settings.owner_allowed_tools,
|
||||
),
|
||||
owner_allowed_tools: owner_settings.owner_allowed_tools,
|
||||
})
|
||||
}
|
||||
RoutineAction::Lightweight { .. } => None,
|
||||
};
|
||||
|
||||
Ok(Json(RoutineDetailResponse {
|
||||
id: routine.id,
|
||||
@@ -173,7 +131,6 @@ pub async fn routines_detail_handler(
|
||||
run_count: routine.run_count,
|
||||
consecutive_failures: routine.consecutive_failures,
|
||||
created_at: routine.created_at.to_rfc3339(),
|
||||
full_job_permissions,
|
||||
recent_runs,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3942,18 +3942,6 @@ function renderRoutineDetail(routine) {
|
||||
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
|
||||
}
|
||||
|
||||
// Action config
|
||||
if (routine.full_job_permissions) {
|
||||
html += '<div class="job-description"><h3>Full Job Permissions</h3>'
|
||||
+ '<div class="job-meta-grid">'
|
||||
+ metaItem('Mode', routine.full_job_permissions.permission_mode)
|
||||
+ metaItem('Owner Default', routine.full_job_permissions.default_permission_mode)
|
||||
+ metaItem('Inherited Tools', (routine.full_job_permissions.owner_allowed_tools || []).join(', ') || '-')
|
||||
+ metaItem('Stored Tools', (routine.full_job_permissions.stored_tool_permissions || []).join(', ') || '-')
|
||||
+ metaItem('Effective Tools', (routine.full_job_permissions.effective_tool_permissions || []).join(', ') || '-')
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
html += '<div class="job-description"><h3>Action</h3>'
|
||||
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
|
||||
|
||||
@@ -4788,10 +4776,6 @@ var AGENT_SETTINGS = [
|
||||
settings: [
|
||||
{ key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 },
|
||||
{ key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 },
|
||||
{ key: 'routines.full_job_default_permission_mode', label: 'cfg.routines_full_job_default_mode.label', description: 'cfg.routines_full_job_default_mode.desc',
|
||||
type: 'select', options: ['inherit_owner', 'explicit', 'copy_owner'] },
|
||||
{ key: 'routines.full_job_owner_allowed_tools', label: 'cfg.routines_full_job_owner_tools.label', description: 'cfg.routines_full_job_owner_tools.desc',
|
||||
type: 'list', placeholder: 'shell, http' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -481,10 +481,6 @@ I18n.register('en', {
|
||||
'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
|
||||
'cfg.routines_cooldown.label': 'Default Cooldown',
|
||||
'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
|
||||
'cfg.routines_full_job_default_mode.label': 'Full Job Default Mode',
|
||||
'cfg.routines_full_job_default_mode.desc': 'Default permission behavior for new full_job routines. When unset, inherit_owner is used.',
|
||||
'cfg.routines_full_job_owner_tools.label': 'Full Job Owner Allowlist',
|
||||
'cfg.routines_full_job_owner_tools.desc': 'Comma-separated tool names that full_job routines may inherit at run time.',
|
||||
|
||||
// Safety settings
|
||||
'cfg.safety_max_output.label': 'Max Output Length',
|
||||
|
||||
@@ -480,10 +480,6 @@ I18n.register('zh-CN', {
|
||||
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
|
||||
'cfg.routines_cooldown.label': '默认冷却时间',
|
||||
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
|
||||
'cfg.routines_full_job_default_mode.label': '完整任务默认权限模式',
|
||||
'cfg.routines_full_job_default_mode.desc': '新建 full_job 定时任务的默认权限行为。未设置时使用 inherit_owner。',
|
||||
'cfg.routines_full_job_owner_tools.label': '完整任务所有者允许工具',
|
||||
'cfg.routines_full_job_owner_tools.desc': '逗号分隔的工具名列表,full_job 定时任务可在运行时继承这些工具权限。',
|
||||
|
||||
// 安全设置
|
||||
'cfg.safety_max_output.label': '最大输出长度',
|
||||
|
||||
@@ -884,20 +884,9 @@ pub struct RoutineDetailResponse {
|
||||
pub run_count: u64,
|
||||
pub consecutive_failures: u32,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_job_permissions: Option<FullJobPermissionInfo>,
|
||||
pub recent_runs: Vec<RoutineRunInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FullJobPermissionInfo {
|
||||
pub permission_mode: String,
|
||||
pub default_permission_mode: String,
|
||||
pub stored_tool_permissions: Vec<String>,
|
||||
pub owner_allowed_tools: Vec<String>,
|
||||
pub effective_tool_permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RoutineRunInfo {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -168,6 +168,9 @@ pub enum ToolError {
|
||||
#[error("Tool {name} requires authentication")]
|
||||
AuthRequired { name: String },
|
||||
|
||||
#[error("Tool {name} is not available for autonomous execution: {reason}")]
|
||||
AutonomousUnavailable { name: String, reason: String },
|
||||
|
||||
#[error("Tool {name} is rate limited, retry after {retry_after:?}")]
|
||||
RateLimited {
|
||||
name: String,
|
||||
|
||||
@@ -463,6 +463,37 @@ fn sanitize_url_for_logging(url: &str) -> String {
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
pub fn owner_id(&self) -> &str {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub async fn active_tool_names(&self) -> HashSet<String> {
|
||||
let mut names = HashSet::new();
|
||||
match self.list(None, false).await {
|
||||
Ok(extensions) => {
|
||||
for extension in extensions {
|
||||
match extension.kind {
|
||||
ExtensionKind::WasmTool if extension.active => {
|
||||
names.insert(extension.name);
|
||||
}
|
||||
ExtensionKind::McpServer if extension.active => {
|
||||
names.extend(extension.tools);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
owner_id = %self.user_id,
|
||||
"Failed to list active extensions while resolving autonomous tool scope: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
mcp_session_manager: Arc<McpSessionManager>,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::extensions::ExtensionManager;
|
||||
|
||||
use super::ToolRegistry;
|
||||
|
||||
pub const AUTONOMOUS_TOOL_DENYLIST: &[&str] = &[
|
||||
"routine_create",
|
||||
"routine_update",
|
||||
"routine_delete",
|
||||
"routine_fire",
|
||||
"event_emit",
|
||||
"create_job",
|
||||
"job_prompt",
|
||||
"restart",
|
||||
"tool_install",
|
||||
"tool_auth",
|
||||
"tool_activate",
|
||||
"tool_remove",
|
||||
"tool_upgrade",
|
||||
"skill_install",
|
||||
"skill_remove",
|
||||
"secret_list",
|
||||
"secret_delete",
|
||||
];
|
||||
|
||||
pub fn is_autonomous_tool_denylisted(tool_name: &str) -> bool {
|
||||
AUTONOMOUS_TOOL_DENYLIST.contains(&tool_name)
|
||||
}
|
||||
|
||||
pub fn autonomous_unavailable_message(tool_name: &str, owner_id: &str) -> String {
|
||||
if is_autonomous_tool_denylisted(tool_name) {
|
||||
format!("Tool '{tool_name}' is not available in autonomous jobs or routines")
|
||||
} else {
|
||||
format!("Tool '{tool_name}' is not currently available for owner '{owner_id}'")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn autonomous_unavailable_error(tool_name: &str, owner_id: &str) -> crate::error::ToolError {
|
||||
crate::error::ToolError::AutonomousUnavailable {
|
||||
name: tool_name.to_string(),
|
||||
reason: autonomous_unavailable_message(tool_name, owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn autonomous_allowed_tool_names(
|
||||
tools: &Arc<ToolRegistry>,
|
||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||
owner_id: &str,
|
||||
) -> HashSet<String> {
|
||||
let mut allowed = tools.builtin_tool_names().await;
|
||||
allowed.retain(|name| !is_autonomous_tool_denylisted(name));
|
||||
|
||||
if let Some(extension_manager) = extension_manager
|
||||
&& extension_manager.owner_id() == owner_id
|
||||
{
|
||||
allowed.extend(
|
||||
extension_manager
|
||||
.active_tool_names()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|name| !is_autonomous_tool_denylisted(name)),
|
||||
);
|
||||
}
|
||||
|
||||
allowed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::SecretString;
|
||||
|
||||
use super::*;
|
||||
use crate::context::JobContext;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
|
||||
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
|
||||
use crate::tools::{Tool, ToolError, ToolOutput};
|
||||
|
||||
struct FakeTool {
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FakeTool {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"test tool"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::text("ok", Duration::from_millis(1)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_test_extension_wasm(tools_dir: &Path, name: &str) {
|
||||
tokio::fs::create_dir_all(tools_dir)
|
||||
.await
|
||||
.expect("create test tools dir");
|
||||
tokio::fs::write(tools_dir.join(format!("{name}.wasm")), b"\0asm")
|
||||
.await
|
||||
.expect("write wasm marker");
|
||||
}
|
||||
|
||||
fn make_extension_manager(
|
||||
tools: Arc<ToolRegistry>,
|
||||
tools_dir: &Path,
|
||||
owner_id: &str,
|
||||
) -> Arc<ExtensionManager> {
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
))
|
||||
.expect("test crypto"),
|
||||
);
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
||||
Arc::new(InMemorySecretsStore::new(crypto));
|
||||
|
||||
Arc::new(ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(McpProcessManager::new()),
|
||||
secrets,
|
||||
tools,
|
||||
Some(Arc::new(HookRegistry::default())),
|
||||
None,
|
||||
tools_dir.to_path_buf(),
|
||||
tools_dir.join("channels"),
|
||||
None,
|
||||
owner_id.to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autonomous_scope_keeps_allowed_builtins_and_blocks_denylisted_builtins() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register_sync(Arc::new(FakeTool { name: "echo" }));
|
||||
tools.register_sync(Arc::new(FakeTool { name: "restart" }));
|
||||
|
||||
let allowed = autonomous_allowed_tool_names(&tools, None, "default").await;
|
||||
|
||||
assert!(allowed.contains("echo"));
|
||||
assert!(!allowed.contains("restart"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autonomous_scope_includes_active_extension_tools_for_matching_owner() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let tools_dir = temp_dir.path().join("wasm-tools");
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools
|
||||
.register(Arc::new(FakeTool { name: "owner_gate" }))
|
||||
.await;
|
||||
write_test_extension_wasm(&tools_dir, "owner_gate").await;
|
||||
let manager = make_extension_manager(tools.clone(), &tools_dir, "default");
|
||||
|
||||
let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await;
|
||||
|
||||
assert!(allowed.contains("owner_gate"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autonomous_scope_excludes_inactive_extension_tools() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let tools_dir = temp_dir.path().join("wasm-tools");
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let manager = make_extension_manager(tools.clone(), &tools_dir, "default");
|
||||
|
||||
let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await;
|
||||
|
||||
assert!(!allowed.contains("owner_gate"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autonomous_scope_excludes_active_extension_tools_for_other_owner() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let tools_dir = temp_dir.path().join("wasm-tools");
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools
|
||||
.register(Arc::new(FakeTool { name: "owner_gate" }))
|
||||
.await;
|
||||
write_test_extension_wasm(&tools_dir, "owner_gate").await;
|
||||
let manager = make_extension_manager(tools.clone(), &tools_dir, "someone-else");
|
||||
|
||||
let allowed = autonomous_allowed_tool_names(&tools, Some(&manager), "default").await;
|
||||
|
||||
assert!(!allowed.contains("owner_gate"));
|
||||
}
|
||||
}
|
||||
+75
-355
@@ -19,9 +19,8 @@ use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
FullJobPermissionDefaultMode, FullJobPermissionMode, NotifyConfig, Routine, RoutineAction,
|
||||
RoutineGuardrails, Trigger, load_full_job_permission_settings, next_cron_fire,
|
||||
normalize_cron_expression, normalize_tool_names,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||
normalize_cron_expression,
|
||||
};
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
@@ -56,21 +55,12 @@ enum NormalizedExecutionMode {
|
||||
FullJob,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RequestedFullJobPermissionMode {
|
||||
Explicit,
|
||||
InheritOwner,
|
||||
CopyOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NormalizedExecutionRequest {
|
||||
mode: NormalizedExecutionMode,
|
||||
context_paths: Vec<String>,
|
||||
use_tools: bool,
|
||||
max_tool_rounds: u32,
|
||||
tool_permissions: Vec<String>,
|
||||
permission_mode: Option<RequestedFullJobPermissionMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -154,16 +144,6 @@ fn execution_properties() -> Value {
|
||||
"maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
|
||||
"default": 3,
|
||||
"description": "Only applies when execution.mode='lightweight' and use_tools=true. Runtime-capped to prevent loops."
|
||||
},
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Only applies when execution.mode='full_job'. These tools are pre-authorized for Always-approval checks."
|
||||
},
|
||||
"permission_mode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit_owner", "explicit", "copy_owner"],
|
||||
"description": "Only applies when execution.mode='full_job'. 'inherit_owner' uses the owner defaults at run time, 'explicit' uses only tool_permissions, and 'copy_owner' snapshots the current owner allowlist into tool_permissions."
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -336,22 +316,12 @@ fn lightweight_execution_variant() -> Value {
|
||||
fn full_job_execution_variant() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"description": "Full-job execution. Uses owner-scoped permission defaults plus tool_permissions and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.",
|
||||
"description": "Full-job execution. Uses the owner's live autonomous tool scope and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["full_job"],
|
||||
"description": "Full-job execution mode."
|
||||
},
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tools pre-authorized for Always-approval checks."
|
||||
},
|
||||
"permission_mode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit_owner", "explicit", "copy_owner"],
|
||||
"description": "When omitted, new routines use the owner default. 'copy_owner' snapshots the current owner allowlist into this routine."
|
||||
}
|
||||
},
|
||||
"required": ["mode"]
|
||||
@@ -369,7 +339,7 @@ fn execution_discovery_schema() -> Value {
|
||||
],
|
||||
"examples": [
|
||||
{ "mode": "lightweight", "use_tools": true, "max_tool_rounds": 3 },
|
||||
{ "mode": "full_job", "permission_mode": "inherit_owner", "tool_permissions": ["message", "http"] }
|
||||
{ "mode": "full_job" }
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -418,9 +388,7 @@ fn routine_create_examples() -> Vec<Value> {
|
||||
"filters": { "repository": "nearai/ironclaw" }
|
||||
},
|
||||
"execution": {
|
||||
"mode": "full_job",
|
||||
"permission_mode": "inherit_owner",
|
||||
"tool_permissions": ["message"]
|
||||
"mode": "full_job"
|
||||
}
|
||||
}),
|
||||
]
|
||||
@@ -433,7 +401,7 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
|
||||
"request.kind='cron' requires request.schedule.".into(),
|
||||
"request.kind='message_event' requires request.pattern.".into(),
|
||||
"request.kind='system_event' requires request.source and request.event_type.".into(),
|
||||
"execution.mode='full_job' uses permission_mode and tool_permissions, and ignores use_tools, max_tool_rounds, and context_paths.".into(),
|
||||
"execution.mode='full_job' uses the owner's live autonomous tool scope and ignores use_tools, max_tool_rounds, and context_paths.".into(),
|
||||
],
|
||||
notes: vec![
|
||||
"Omitting execution defaults to lightweight mode.".into(),
|
||||
@@ -590,22 +558,6 @@ fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
|
||||
"description": "Compatibility alias for execution.max_tool_rounds."
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"tool_permissions".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Compatibility alias for execution.tool_permissions."
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"permission_mode".to_string(),
|
||||
serde_json::json!({
|
||||
"type": "string",
|
||||
"enum": ["inherit_owner", "explicit", "copy_owner"],
|
||||
"description": "Compatibility alias for execution.permission_mode."
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"notify_channel".to_string(),
|
||||
serde_json::json!({
|
||||
@@ -684,16 +636,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description"
|
||||
},
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Updated Always-approval tool allowlist for full_job routines only."
|
||||
},
|
||||
"permission_mode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit_owner", "explicit", "copy_owner"],
|
||||
"description": "Updated permission mode for full_job routines only. 'copy_owner' snapshots the current owner allowlist into the routine and persists as explicit."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
@@ -739,27 +681,6 @@ fn u64_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Opti
|
||||
}
|
||||
|
||||
fn string_array_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Vec<String> {
|
||||
normalize_tool_names(
|
||||
nested_object(params, group)
|
||||
.and_then(|obj| obj.get(field))
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| params.get(*alias).and_then(Value::as_array))
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|value| value.as_str().map(String::from)),
|
||||
)
|
||||
}
|
||||
|
||||
fn optional_string_array_field(
|
||||
params: &Value,
|
||||
group: &str,
|
||||
field: &str,
|
||||
aliases: &[&str],
|
||||
) -> Option<Vec<String>> {
|
||||
nested_object(params, group)
|
||||
.and_then(|obj| obj.get(field))
|
||||
.and_then(Value::as_array)
|
||||
@@ -769,11 +690,21 @@ fn optional_string_array_field(
|
||||
.find_map(|alias| params.get(*alias).and_then(Value::as_array))
|
||||
})
|
||||
.map(|arr| {
|
||||
normalize_tool_names(
|
||||
arr.iter()
|
||||
.filter_map(|value| value.as_str().map(String::from)),
|
||||
)
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter_map(|value| {
|
||||
if seen.insert(value.to_string()) {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn object_field(
|
||||
@@ -912,20 +843,6 @@ fn parse_execution_mode(value: Option<String>) -> Result<NormalizedExecutionMode
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_requested_full_job_permission_mode(
|
||||
value: Option<String>,
|
||||
) -> Result<Option<RequestedFullJobPermissionMode>, ToolError> {
|
||||
match value.as_deref() {
|
||||
None => Ok(None),
|
||||
Some("explicit") => Ok(Some(RequestedFullJobPermissionMode::Explicit)),
|
||||
Some("inherit_owner") => Ok(Some(RequestedFullJobPermissionMode::InheritOwner)),
|
||||
Some("copy_owner") => Ok(Some(RequestedFullJobPermissionMode::CopyOwner)),
|
||||
Some(other) => Err(ToolError::InvalidParameters(format!(
|
||||
"unknown full_job permission_mode: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_routine_execution(params: &Value) -> Result<NormalizedExecutionRequest, ToolError> {
|
||||
let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?;
|
||||
let context_paths =
|
||||
@@ -935,26 +852,12 @@ fn parse_routine_execution(params: &Value) -> Result<NormalizedExecutionRequest,
|
||||
.unwrap_or(3)
|
||||
.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64)
|
||||
as u32;
|
||||
let tool_permissions = string_array_field(
|
||||
params,
|
||||
"execution",
|
||||
"tool_permissions",
|
||||
&["tool_permissions"],
|
||||
);
|
||||
let permission_mode = parse_requested_full_job_permission_mode(string_field(
|
||||
params,
|
||||
"execution",
|
||||
"permission_mode",
|
||||
&["permission_mode"],
|
||||
))?;
|
||||
|
||||
Ok(NormalizedExecutionRequest {
|
||||
mode,
|
||||
context_paths,
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1015,89 +918,24 @@ fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_routine_action(
|
||||
store: &dyn Database,
|
||||
user_id: &str,
|
||||
fn build_routine_action(
|
||||
name: &str,
|
||||
prompt: &str,
|
||||
execution: &NormalizedExecutionRequest,
|
||||
) -> Result<RoutineAction, ToolError> {
|
||||
) -> RoutineAction {
|
||||
match execution.mode {
|
||||
NormalizedExecutionMode::Lightweight => Ok(RoutineAction::Lightweight {
|
||||
NormalizedExecutionMode::Lightweight => RoutineAction::Lightweight {
|
||||
prompt: prompt.to_string(),
|
||||
context_paths: execution.context_paths.clone(),
|
||||
max_tokens: 4096,
|
||||
use_tools: execution.use_tools,
|
||||
max_tool_rounds: execution.max_tool_rounds,
|
||||
}),
|
||||
NormalizedExecutionMode::FullJob => {
|
||||
let mut owner_settings = None;
|
||||
let requested_mode = match execution.permission_mode {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
let settings = load_full_job_permission_settings(store, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to load routine permission settings: {e}"
|
||||
))
|
||||
})?;
|
||||
let mode = match settings.default_mode {
|
||||
FullJobPermissionDefaultMode::Explicit => {
|
||||
RequestedFullJobPermissionMode::Explicit
|
||||
}
|
||||
FullJobPermissionDefaultMode::InheritOwner => {
|
||||
RequestedFullJobPermissionMode::InheritOwner
|
||||
}
|
||||
FullJobPermissionDefaultMode::CopyOwner => {
|
||||
RequestedFullJobPermissionMode::CopyOwner
|
||||
}
|
||||
};
|
||||
owner_settings = Some(settings);
|
||||
mode
|
||||
}
|
||||
};
|
||||
let (permission_mode, tool_permissions) = match requested_mode {
|
||||
RequestedFullJobPermissionMode::Explicit => (
|
||||
FullJobPermissionMode::Explicit,
|
||||
execution.tool_permissions.clone(),
|
||||
),
|
||||
RequestedFullJobPermissionMode::InheritOwner => (
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
execution.tool_permissions.clone(),
|
||||
),
|
||||
RequestedFullJobPermissionMode::CopyOwner => {
|
||||
let owner_allowed_tools = match owner_settings {
|
||||
Some(settings) => settings.owner_allowed_tools,
|
||||
None => {
|
||||
load_full_job_permission_settings(store, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to load routine permission settings: {e}"
|
||||
))
|
||||
})?
|
||||
.owner_allowed_tools
|
||||
}
|
||||
};
|
||||
(
|
||||
FullJobPermissionMode::Explicit,
|
||||
normalize_tool_names(
|
||||
owner_allowed_tools
|
||||
.into_iter()
|
||||
.chain(execution.tool_permissions.iter().cloned()),
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(RoutineAction::FullJob {
|
||||
title: name.to_string(),
|
||||
description: prompt.to_string(),
|
||||
max_iterations: 10,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
},
|
||||
NormalizedExecutionMode::FullJob => RoutineAction::FullJob {
|
||||
title: name.to_string(),
|
||||
description: prompt.to_string(),
|
||||
max_iterations: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,13 +946,6 @@ fn routine_requests_full_job(params: &Value) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn routine_permission_fields_present(params: &Value) -> bool {
|
||||
nested_object(params, "execution").is_some_and(|execution| {
|
||||
execution.contains_key("tool_permissions") || execution.contains_key("permission_mode")
|
||||
}) || params.get("tool_permissions").is_some()
|
||||
|| params.get("permission_mode").is_some()
|
||||
}
|
||||
|
||||
fn event_emit_schema(include_source_alias: bool) -> Value {
|
||||
let mut schema = serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -1241,14 +1072,8 @@ impl Tool for RoutineCreateTool {
|
||||
let start = std::time::Instant::now();
|
||||
let normalized = parse_routine_create_request(¶ms)?;
|
||||
let trigger = build_routine_trigger(&normalized.trigger);
|
||||
let action = build_routine_action(
|
||||
self.store.as_ref(),
|
||||
&ctx.user_id,
|
||||
&normalized.name,
|
||||
&normalized.prompt,
|
||||
&normalized.execution,
|
||||
)
|
||||
.await?;
|
||||
let action =
|
||||
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
|
||||
|
||||
// Compute next fire time for cron
|
||||
let next_fire = if let Trigger::Cron {
|
||||
@@ -1412,22 +1237,13 @@ impl Tool for RoutineUpdateTool {
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
|
||||
or full_job permission settings. 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."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
routine_update_parameters_schema()
|
||||
}
|
||||
|
||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||
if routine_permission_fields_present(params) {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
} else {
|
||||
ApprovalRequirement::Never
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
@@ -1460,72 +1276,6 @@ impl Tool for RoutineUpdateTool {
|
||||
}
|
||||
}
|
||||
|
||||
let requested_permission_mode = parse_requested_full_job_permission_mode(string_field(
|
||||
¶ms,
|
||||
"execution",
|
||||
"permission_mode",
|
||||
&["permission_mode"],
|
||||
))?;
|
||||
let requested_tool_permissions = optional_string_array_field(
|
||||
¶ms,
|
||||
"execution",
|
||||
"tool_permissions",
|
||||
&["tool_permissions"],
|
||||
);
|
||||
let updates_permissions =
|
||||
requested_permission_mode.is_some() || requested_tool_permissions.is_some();
|
||||
|
||||
if updates_permissions {
|
||||
match &mut routine.action {
|
||||
RoutineAction::FullJob {
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
let next_tool_permissions =
|
||||
requested_tool_permissions.unwrap_or_else(|| tool_permissions.clone());
|
||||
match requested_permission_mode {
|
||||
Some(RequestedFullJobPermissionMode::Explicit) => {
|
||||
*permission_mode = FullJobPermissionMode::Explicit;
|
||||
*tool_permissions = next_tool_permissions;
|
||||
}
|
||||
Some(RequestedFullJobPermissionMode::InheritOwner) => {
|
||||
*permission_mode = FullJobPermissionMode::InheritOwner;
|
||||
*tool_permissions = next_tool_permissions;
|
||||
}
|
||||
Some(RequestedFullJobPermissionMode::CopyOwner) => {
|
||||
let owner_settings = load_full_job_permission_settings(
|
||||
self.store.as_ref(),
|
||||
&ctx.user_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to load routine permission settings: {e}"
|
||||
))
|
||||
})?;
|
||||
*permission_mode = FullJobPermissionMode::Explicit;
|
||||
*tool_permissions = normalize_tool_names(
|
||||
owner_settings
|
||||
.owner_allowed_tools
|
||||
.into_iter()
|
||||
.chain(next_tool_permissions),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
*tool_permissions = next_tool_permissions;
|
||||
}
|
||||
}
|
||||
}
|
||||
RoutineAction::Lightweight { .. } => {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"permission_mode and tool_permissions can only be updated for full_job routines"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate timezone param if provided
|
||||
let new_timezone = params
|
||||
.get("timezone")
|
||||
@@ -1936,8 +1686,6 @@ mod tests {
|
||||
"context_paths",
|
||||
"use_tools",
|
||||
"max_tool_rounds",
|
||||
"tool_permissions",
|
||||
"permission_mode",
|
||||
"notify_channel",
|
||||
"notify_user",
|
||||
"cooldown_secs",
|
||||
@@ -2036,8 +1784,7 @@ mod tests {
|
||||
"timezone": "UTC"
|
||||
},
|
||||
"execution": {
|
||||
"mode": "full_job",
|
||||
"tool_permissions": ["message", "http"]
|
||||
"mode": "full_job"
|
||||
},
|
||||
"delivery": {
|
||||
"channel": "telegram",
|
||||
@@ -2062,11 +1809,6 @@ mod tests {
|
||||
matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob),
|
||||
"expected full_job execution mode",
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.execution.tool_permissions,
|
||||
vec!["message".to_string(), "http".to_string()],
|
||||
);
|
||||
assert_eq!(parsed.execution.permission_mode, None);
|
||||
assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram"));
|
||||
assert_eq!(parsed.delivery.user.as_deref(), Some("ops-team"));
|
||||
assert_eq!(parsed.cooldown_secs, 30);
|
||||
@@ -2108,6 +1850,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_context_paths_with_trim_drop_empty_and_stable_dedupe() {
|
||||
let params = serde_json::json!({
|
||||
"name": "deploy-watch",
|
||||
"prompt": "Look for deploy requests.",
|
||||
"request": {
|
||||
"kind": "manual"
|
||||
},
|
||||
"execution": {
|
||||
"context_paths": [
|
||||
" context/deploy.md ",
|
||||
"",
|
||||
" ",
|
||||
"context/deploy.md",
|
||||
"context/notes.md"
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let parsed =
|
||||
parse_routine_create_request(¶ms).expect("parse context_paths normalization");
|
||||
|
||||
assert_eq!(
|
||||
parsed.execution.context_paths,
|
||||
vec![
|
||||
"context/deploy.md".to_string(),
|
||||
"context/notes.md".to_string()
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_grouped_system_event_request() {
|
||||
let params = serde_json::json!({
|
||||
@@ -2187,7 +1960,6 @@ mod tests {
|
||||
"event_pattern": "hello",
|
||||
"event_channel": "telegram",
|
||||
"action_type": "full_job",
|
||||
"tool_permissions": ["message"],
|
||||
"notify_channel": "telegram",
|
||||
"notify_user": "123"
|
||||
});
|
||||
@@ -2206,10 +1978,6 @@ mod tests {
|
||||
matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob),
|
||||
"expected full_job execution mode",
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.execution.tool_permissions,
|
||||
vec!["message".to_string()],
|
||||
);
|
||||
assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram"));
|
||||
assert_eq!(parsed.delivery.user.as_deref(), Some("123"));
|
||||
}
|
||||
@@ -2396,9 +2164,8 @@ mod tests {
|
||||
.and_then(Value::as_object)
|
||||
.expect("full_job properties");
|
||||
assert!(
|
||||
full_job_props.contains_key("tool_permissions")
|
||||
&& full_job_props.contains_key("permission_mode"),
|
||||
"full_job variant should expose permission fields",
|
||||
full_job_props.len() == 1 && full_job_props.contains_key("mode"),
|
||||
"full_job variant should only expose the execution mode",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2503,8 +2270,6 @@ mod tests {
|
||||
"schedule",
|
||||
"timezone",
|
||||
"description",
|
||||
"tool_permissions",
|
||||
"permission_mode",
|
||||
] {
|
||||
let _ = schema_property(&schema, field);
|
||||
}
|
||||
@@ -2587,71 +2352,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn build_full_job_action_defaults_to_inherit_owner_for_new_routines() {
|
||||
let (db, _tmp) = crate::testing::test_db().await;
|
||||
#[test]
|
||||
fn build_full_job_action_uses_live_owner_scope_defaults() {
|
||||
let execution = NormalizedExecutionRequest {
|
||||
mode: NormalizedExecutionMode::FullJob,
|
||||
context_paths: Vec::new(),
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
tool_permissions: vec!["shell".to_string()],
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let action =
|
||||
build_routine_action(db.as_ref(), "default", "issue-1316", "Run it", &execution)
|
||||
.await
|
||||
.expect("build action");
|
||||
let action = build_routine_action("issue-1316", "Run it", &execution);
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
RoutineAction::FullJob {
|
||||
permission_mode: FullJobPermissionMode::InheritOwner,
|
||||
tool_permissions,
|
||||
..
|
||||
} if tool_permissions == vec!["shell".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn build_full_job_action_copy_owner_snapshots_allowlist() {
|
||||
let (db, _tmp) = crate::testing::test_db().await;
|
||||
db.set_setting(
|
||||
"default",
|
||||
crate::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY,
|
||||
&serde_json::json!(["http", "shell"]),
|
||||
)
|
||||
.await
|
||||
.expect("set owner allowlist");
|
||||
let execution = NormalizedExecutionRequest {
|
||||
mode: NormalizedExecutionMode::FullJob,
|
||||
context_paths: Vec::new(),
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
tool_permissions: vec!["message".to_string(), "shell".to_string()],
|
||||
permission_mode: Some(RequestedFullJobPermissionMode::CopyOwner),
|
||||
};
|
||||
|
||||
let action =
|
||||
build_routine_action(db.as_ref(), "default", "issue-1316", "Run it", &execution)
|
||||
.await
|
||||
.expect("build action");
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
RoutineAction::FullJob {
|
||||
permission_mode: FullJobPermissionMode::Explicit,
|
||||
tool_permissions,
|
||||
..
|
||||
} if tool_permissions
|
||||
== vec![
|
||||
"http".to_string(),
|
||||
"shell".to_string(),
|
||||
"message".to_string(),
|
||||
]
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
} if title == "issue-1316"
|
||||
&& description == "Run it"
|
||||
&& max_iterations == 10
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Delegate tasks to other services
|
||||
//! - Build new software and tools
|
||||
|
||||
mod autonomy;
|
||||
pub mod builder;
|
||||
pub mod builtin;
|
||||
mod coercion;
|
||||
@@ -20,6 +21,10 @@ pub mod wasm;
|
||||
mod registry;
|
||||
mod tool;
|
||||
|
||||
pub use autonomy::{
|
||||
AUTONOMOUS_TOOL_DENYLIST, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
||||
autonomous_unavailable_message, is_autonomous_tool_denylisted,
|
||||
};
|
||||
pub use builder::{
|
||||
BuildPhase, BuildRequirement, BuildResult, BuildSoftwareTool, BuilderConfig, Language,
|
||||
LlmSoftwareBuilder, SoftwareBuilder, SoftwareType, Template, TemplateEngine, TemplateType,
|
||||
|
||||
+43
-8
@@ -83,7 +83,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
/// Registry of available tools.
|
||||
pub struct ToolRegistry {
|
||||
tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
|
||||
/// Tracks which names were registered as built-in (protected from shadowing).
|
||||
/// Tracks which names were registered via the built-in startup path.
|
||||
builtin_names: RwLock<std::collections::HashSet<String>>,
|
||||
/// Shared credential registry populated by WASM tools, consumed by HTTP tool.
|
||||
credential_registry: Option<Arc<SharedCredentialRegistry>>,
|
||||
@@ -138,10 +138,12 @@ impl ToolRegistry {
|
||||
&self.rate_limiter
|
||||
}
|
||||
|
||||
/// Register a tool. Rejects dynamic tools that try to shadow a built-in name.
|
||||
/// Register a tool. Rejects dynamic tools that try to shadow a protected built-in name.
|
||||
pub async fn register(&self, tool: Arc<dyn Tool>) {
|
||||
let name = tool.name().to_string();
|
||||
if self.builtin_names.read().await.contains(&name) {
|
||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str())
|
||||
&& self.builtin_names.read().await.contains(&name)
|
||||
{
|
||||
tracing::warn!(
|
||||
tool = %name,
|
||||
"Rejected tool registration: would shadow a built-in tool"
|
||||
@@ -157,10 +159,7 @@ impl ToolRegistry {
|
||||
let name = tool.name().to_string();
|
||||
if let Ok(mut tools) = self.tools.try_write() {
|
||||
tools.insert(name.clone(), tool);
|
||||
// Mark as built-in so it can't be shadowed later
|
||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str())
|
||||
&& let Ok(mut builtins) = self.builtin_names.try_write()
|
||||
{
|
||||
if let Ok(mut builtins) = self.builtin_names.try_write() {
|
||||
builtins.insert(name.clone());
|
||||
}
|
||||
tracing::debug!("Registered tool: {}", name);
|
||||
@@ -210,6 +209,11 @@ impl ToolRegistry {
|
||||
self.tools.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get the set of built-in tool names currently registered.
|
||||
pub async fn builtin_tool_names(&self) -> std::collections::HashSet<String> {
|
||||
self.builtin_names.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get tool definitions for LLM function calling.
|
||||
pub async fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
let mut defs: Vec<ToolDefinition> = self
|
||||
@@ -888,7 +892,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_builtin_tool_cannot_be_shadowed() {
|
||||
let registry = ToolRegistry::new();
|
||||
// Register echo as built-in (uses register_sync which marks protected names)
|
||||
// Register echo as built-in (uses register_sync and echo is protected).
|
||||
registry.register_sync(Arc::new(EchoTool));
|
||||
assert!(registry.has("echo").await);
|
||||
|
||||
@@ -935,6 +939,37 @@ mod tests {
|
||||
assert_ne!(desc, "EVIL SHADOW");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builtin_tool_names_include_non_protected_sync_tools() {
|
||||
struct NonProtectedBuiltin;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for NonProtectedBuiltin {
|
||||
fn name(&self) -> &str {
|
||||
"owner_gate"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"test builtin"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &crate::context::JobContext,
|
||||
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
let registry = ToolRegistry::new();
|
||||
registry.register_sync(Arc::new(NonProtectedBuiltin));
|
||||
|
||||
let builtins = registry.builtin_tool_names().await;
|
||||
assert!(builtins.contains("owner_gate"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_register_and_read_no_panic() {
|
||||
use std::sync::Arc as StdArc;
|
||||
|
||||
+18
-22
@@ -28,30 +28,29 @@ impl ApprovalRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Approval context for autonomous tool execution (routines, background jobs).
|
||||
/// Precomputed autonomous tool scope for background jobs and routines.
|
||||
///
|
||||
/// Interactive sessions don't use this type — they rely on session-level
|
||||
/// auto-approve lists managed by the UI. This enum models only the autonomous
|
||||
/// case where no interactive user is present.
|
||||
/// Interactive sessions don't use this type — they still rely on
|
||||
/// `requires_approval()` and session-level approval state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ApprovalContext {
|
||||
/// Autonomous job with no interactive user. `UnlessAutoApproved` tools are
|
||||
/// pre-approved. `Always` tools are blocked unless listed in `allowed_tools`.
|
||||
/// Autonomous job with no interactive user. Only tools in `allowed_tools`
|
||||
/// may run; interactive approval requirements are ignored.
|
||||
Autonomous {
|
||||
/// Tool names that are pre-authorized even for `Always` approval.
|
||||
/// Tool names that may run autonomously for this job/run.
|
||||
allowed_tools: std::collections::HashSet<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApprovalContext {
|
||||
/// Create an autonomous context with no extra tool permissions.
|
||||
/// Create an autonomous context with no allowed tools.
|
||||
pub fn autonomous() -> Self {
|
||||
Self::Autonomous {
|
||||
allowed_tools: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an autonomous context with specific tools pre-authorized.
|
||||
/// Create an autonomous context with specific allowed tools.
|
||||
pub fn autonomous_with_tools(tools: impl IntoIterator<Item = String>) -> Self {
|
||||
Self::Autonomous {
|
||||
allowed_tools: tools.into_iter().collect(),
|
||||
@@ -59,13 +58,9 @@ impl ApprovalContext {
|
||||
}
|
||||
|
||||
/// Check whether a tool invocation is blocked in this context.
|
||||
pub fn is_blocked(&self, tool_name: &str, requirement: ApprovalRequirement) -> bool {
|
||||
pub fn is_blocked(&self, tool_name: &str, _requirement: ApprovalRequirement) -> bool {
|
||||
match self {
|
||||
Self::Autonomous { allowed_tools } => match requirement {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => false,
|
||||
ApprovalRequirement::Always => !allowed_tools.contains(tool_name),
|
||||
},
|
||||
Self::Autonomous { allowed_tools } => !allowed_tools.contains(tool_name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,26 +884,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_context_autonomous_allows_unless_auto_approved() {
|
||||
fn test_approval_context_autonomous_blocks_tools_not_in_scope() {
|
||||
let ctx = ApprovalContext::autonomous();
|
||||
assert!(!ctx.is_blocked("shell", ApprovalRequirement::Never));
|
||||
assert!(!ctx.is_blocked("shell", ApprovalRequirement::UnlessAutoApproved));
|
||||
assert!(ctx.is_blocked("shell", ApprovalRequirement::Never));
|
||||
assert!(ctx.is_blocked("shell", ApprovalRequirement::UnlessAutoApproved));
|
||||
assert!(ctx.is_blocked("shell", ApprovalRequirement::Always));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_context_autonomous_with_tools_allows_always() {
|
||||
fn test_approval_context_autonomous_with_tools_allows_registered_name() {
|
||||
let ctx =
|
||||
ApprovalContext::autonomous_with_tools(["shell".to_string(), "message".to_string()]);
|
||||
assert!(!ctx.is_blocked("shell", ApprovalRequirement::Never));
|
||||
assert!(!ctx.is_blocked("shell", ApprovalRequirement::Always));
|
||||
assert!(!ctx.is_blocked("message", ApprovalRequirement::Always));
|
||||
assert!(ctx.is_blocked("http", ApprovalRequirement::Always));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_context_never_is_not_blocked() {
|
||||
fn test_approval_context_blocks_never_when_not_in_scope() {
|
||||
let ctx = ApprovalContext::autonomous();
|
||||
assert!(!ctx.is_blocked("any_tool", ApprovalRequirement::Never));
|
||||
assert!(ctx.is_blocked("any_tool", ApprovalRequirement::Never));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -946,7 +942,7 @@ mod tests {
|
||||
"other",
|
||||
ApprovalRequirement::Always
|
||||
));
|
||||
assert!(!ApprovalContext::is_blocked_or_default(
|
||||
assert!(ApprovalContext::is_blocked_or_default(
|
||||
&ctx,
|
||||
"any",
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
|
||||
+49
-18
@@ -30,7 +30,9 @@ use crate::llm::{
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::execute::process_tool_result;
|
||||
use crate::tools::rate_limiter::RateLimitResult;
|
||||
use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params, redact_params};
|
||||
use crate::tools::{
|
||||
ApprovalContext, ToolRegistry, autonomous_unavailable_error, prepare_tool_params, redact_params,
|
||||
};
|
||||
|
||||
/// Shared dependencies for worker execution.
|
||||
///
|
||||
@@ -486,22 +488,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
|
||||
let normalized_params = prepare_tool_params(tool.as_ref(), params);
|
||||
|
||||
// Fetch job context early so we have the real user_id for approval, hooks,
|
||||
// and rate limiting decisions.
|
||||
let mut job_ctx = deps.context_manager.get_context(job_id).await?;
|
||||
// Propagate http_interceptor for trace recording/replay
|
||||
if job_ctx.http_interceptor.is_none() {
|
||||
job_ctx.http_interceptor = deps.http_interceptor.clone();
|
||||
}
|
||||
|
||||
// Check approval: use context-aware check if available, else block all non-Never tools
|
||||
let requirement = tool.requires_approval(&normalized_params);
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&deps.approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
return Err(crate::error::ToolError::AuthRequired {
|
||||
name: tool_name.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
// Fetch job context early so we have the real user_id for hooks and rate limiting
|
||||
let mut job_ctx = deps.context_manager.get_context(job_id).await?;
|
||||
// Propagate http_interceptor for trace recording/replay
|
||||
if job_ctx.http_interceptor.is_none() {
|
||||
job_ctx.http_interceptor = deps.http_interceptor.clone();
|
||||
return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
|
||||
}
|
||||
|
||||
// Check per-tool rate limit before running hooks or executing (cheaper check first)
|
||||
@@ -761,12 +761,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
);
|
||||
reason_ctx.messages.push(message);
|
||||
|
||||
match &result {
|
||||
match result {
|
||||
Ok(raw_output) => {
|
||||
let sanitized = self
|
||||
.deps
|
||||
.safety
|
||||
.sanitize_tool_output(&selection.tool_name, raw_output);
|
||||
.sanitize_tool_output(&selection.tool_name, &raw_output);
|
||||
self.log_event(
|
||||
"tool_result",
|
||||
serde_json::json!({
|
||||
@@ -807,7 +807,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
if matches!(
|
||||
&e,
|
||||
Error::Tool(crate::error::ToolError::AutonomousUnavailable { .. })
|
||||
) {
|
||||
Err(e)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1802,7 +1809,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approval_context_unblocks_unless_auto_approved() {
|
||||
async fn test_approval_context_requires_explicit_allowed_tool_names() {
|
||||
let worker_blocked = make_worker_with_approval(vec![Arc::new(ApprovalTool)], None).await;
|
||||
let result = worker_blocked
|
||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||
@@ -1815,13 +1822,18 @@ mod tests {
|
||||
|
||||
let worker_allowed = make_worker_with_approval(
|
||||
vec![Arc::new(ApprovalTool)],
|
||||
Some(crate::tools::ApprovalContext::autonomous()),
|
||||
Some(crate::tools::ApprovalContext::autonomous_with_tools([
|
||||
"needs_approval".to_string(),
|
||||
])),
|
||||
)
|
||||
.await;
|
||||
let result = worker_allowed
|
||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
assert!(result.is_ok(), "Should be allowed with autonomous context"); // safety: test
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should be allowed when the tool is in the autonomous scope"
|
||||
); // safety: test
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1857,6 +1869,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_approval_context_returns_structured_autonomous_unavailable_error() {
|
||||
let worker = make_worker_with_approval(
|
||||
vec![Arc::new(AlwaysApprovalTool)],
|
||||
Some(crate::tools::ApprovalContext::autonomous()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = worker
|
||||
.execute_tool("always_approval", &serde_json::json!({}))
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(Error::Tool(crate::error::ToolError::AutonomousUnavailable { name, .. }))
|
||||
if name == "always_approval"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_budget_exceeded_fails_job() {
|
||||
let worker = make_worker(vec![]).await;
|
||||
|
||||
@@ -15,8 +15,7 @@ mod tests {
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::agent::routine::{
|
||||
FullJobPermissionMode, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus,
|
||||
Trigger,
|
||||
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
||||
};
|
||||
use ironclaw::context::{JobContext, JobState};
|
||||
use ironclaw::db::Database;
|
||||
@@ -46,8 +45,6 @@ mod tests {
|
||||
title: "Test job".to_string(),
|
||||
description: "Test description".to_string(),
|
||||
max_iterations: 5,
|
||||
tool_permissions: vec![],
|
||||
permission_mode: FullJobPermissionMode::Explicit,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
|
||||
@@ -10,7 +10,7 @@ mod support;
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use ironclaw::agent::routine::{FullJobPermissionMode, RoutineAction, Trigger};
|
||||
use ironclaw::agent::routine::{RoutineAction, Trigger};
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::LlmTrace;
|
||||
@@ -356,15 +356,8 @@ mod tests {
|
||||
}
|
||||
|
||||
match &routine.action {
|
||||
RoutineAction::FullJob {
|
||||
description,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
RoutineAction::FullJob { description, .. } => {
|
||||
assert!(description.contains("Summarize the new issue"));
|
||||
assert_eq!(tool_permissions, &vec!["shell".to_string()]);
|
||||
assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner);
|
||||
}
|
||||
other => panic!("expected full_job action, got {other:?}"),
|
||||
}
|
||||
@@ -412,18 +405,8 @@ mod tests {
|
||||
}
|
||||
|
||||
match &routine.action {
|
||||
RoutineAction::FullJob {
|
||||
description,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
RoutineAction::FullJob { description, .. } => {
|
||||
assert!(description.contains("Prepare the morning digest"));
|
||||
assert_eq!(
|
||||
tool_permissions,
|
||||
&vec!["message".to_string(), "http".to_string()]
|
||||
);
|
||||
assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner);
|
||||
}
|
||||
other => panic!("expected full_job action, got {other:?}"),
|
||||
}
|
||||
|
||||
+380
-88
@@ -8,27 +8,33 @@ mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use libsql::params;
|
||||
use secrecy::SecretString;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::agent::routine::{
|
||||
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun,
|
||||
RunStatus, Trigger,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
||||
};
|
||||
use ironclaw::agent::routine_engine::RoutineEngine;
|
||||
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler};
|
||||
use ironclaw::agent::{
|
||||
HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler, SchedulerDeps,
|
||||
};
|
||||
use ironclaw::channels::IncomingMessage;
|
||||
use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig};
|
||||
use ironclaw::context::{ContextManager, JobContext};
|
||||
use ironclaw::db::{Database, libsql::LibSqlBackend};
|
||||
use ironclaw::extensions::ExtensionManager;
|
||||
use ironclaw::hooks::HookRegistry;
|
||||
use ironclaw::llm::LlmProvider;
|
||||
use ironclaw::safety::SafetyLayer;
|
||||
use ironclaw::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
|
||||
use ironclaw::tools::builtin::routine::RoutineUpdateTool;
|
||||
use ironclaw::tools::mcp::{McpProcessManager, McpSessionManager};
|
||||
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry};
|
||||
use ironclaw::workspace::Workspace;
|
||||
use ironclaw::workspace::hygiene::HygieneConfig;
|
||||
@@ -165,11 +171,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_full_job_routine(
|
||||
name: &str,
|
||||
permission_mode: FullJobPermissionMode,
|
||||
tool_permissions: Vec<String>,
|
||||
) -> Routine {
|
||||
fn make_full_job_routine(name: &str) -> Routine {
|
||||
Routine {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
@@ -181,8 +183,6 @@ mod tests {
|
||||
title: name.to_string(),
|
||||
description: "Use the owner-gated tool when permitted.".to_string(),
|
||||
max_iterations: 3,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: Duration::from_secs(0),
|
||||
@@ -234,27 +234,112 @@ mod tests {
|
||||
LlmTrace::single_turn("test-owner-gate", "run owner gate", steps)
|
||||
}
|
||||
|
||||
async fn setup_owner_gate_engine(db: Arc<dyn Database>, trace: LlmTrace) -> Arc<RoutineEngine> {
|
||||
fn owner_gate_lightweight_trace() -> LlmTrace {
|
||||
LlmTrace::single_turn(
|
||||
"test-owner-gate-lightweight",
|
||||
"run owner gate",
|
||||
vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_owner_gate".to_string(),
|
||||
name: "owner_gate".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
input_tokens: 40,
|
||||
output_tokens: 10,
|
||||
},
|
||||
expected_tool_results: vec![],
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "ROUTINE_OK".to_string(),
|
||||
input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
},
|
||||
expected_tool_results: vec![],
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async fn write_test_extension_wasm(tools_dir: &Path, name: &str) {
|
||||
tokio::fs::create_dir_all(tools_dir)
|
||||
.await
|
||||
.expect("create test wasm tools dir");
|
||||
tokio::fs::write(tools_dir.join(format!("{name}.wasm")), b"\0asm")
|
||||
.await
|
||||
.expect("write test wasm tool marker");
|
||||
}
|
||||
|
||||
fn make_test_extension_manager(
|
||||
tools: Arc<ToolRegistry>,
|
||||
tools_dir: &Path,
|
||||
owner_id: &str,
|
||||
) -> Arc<ExtensionManager> {
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
))
|
||||
.expect("test crypto"),
|
||||
);
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
||||
Arc::new(InMemorySecretsStore::new(crypto));
|
||||
Arc::new(ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(McpProcessManager::new()),
|
||||
secrets,
|
||||
tools,
|
||||
None,
|
||||
None,
|
||||
tools_dir.to_path_buf(),
|
||||
tools_dir.join("channels"),
|
||||
None,
|
||||
owner_id.to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn setup_owner_gate_engine(
|
||||
db: Arc<dyn Database>,
|
||||
trace: LlmTrace,
|
||||
tools_dir: &Path,
|
||||
extension_owner_id: Option<&str>,
|
||||
activate_owner_gate: bool,
|
||||
) -> Arc<RoutineEngine> {
|
||||
let ws = create_workspace(&db);
|
||||
let (notify_tx, _rx) = tokio::sync::mpsc::channel(16);
|
||||
let registry = Arc::new(ToolRegistry::new());
|
||||
registry
|
||||
.register(Arc::new(OwnerGateTool { store: db.clone() }))
|
||||
.await;
|
||||
if extension_owner_id.is_some() {
|
||||
registry
|
||||
.register(Arc::new(OwnerGateTool { store: db.clone() }))
|
||||
.await;
|
||||
}
|
||||
if activate_owner_gate {
|
||||
write_test_extension_wasm(tools_dir, "owner_gate").await;
|
||||
}
|
||||
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
let llm: Arc<dyn LlmProvider> = Arc::new(TraceLlm::from_trace(trace));
|
||||
let extension_manager = extension_owner_id
|
||||
.map(|owner_id| make_test_extension_manager(registry.clone(), tools_dir, owner_id));
|
||||
let scheduler = Arc::new(Scheduler::new(
|
||||
AgentConfig::for_testing(),
|
||||
Arc::new(ContextManager::new(5)),
|
||||
llm.clone(),
|
||||
safety.clone(),
|
||||
registry.clone(),
|
||||
Some(db.clone()),
|
||||
Arc::new(HookRegistry::new()),
|
||||
SchedulerDeps {
|
||||
tools: registry.clone(),
|
||||
extension_manager: extension_manager.clone(),
|
||||
store: Some(db.clone()),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
},
|
||||
));
|
||||
|
||||
Arc::new(RoutineEngine::new(
|
||||
@@ -264,9 +349,10 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
Some(scheduler),
|
||||
extension_manager,
|
||||
registry,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
SandboxReadiness::Available,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -303,6 +389,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_any_run_completion(db: &Arc<dyn Database>, routine_id: Uuid) -> RoutineRun {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let runs = db
|
||||
.list_routine_runs(routine_id, 10)
|
||||
.await
|
||||
.expect("list_routine_runs");
|
||||
if let Some(run) = runs
|
||||
.into_iter()
|
||||
.find(|run| run.status != RunStatus::Running)
|
||||
{
|
||||
return run;
|
||||
}
|
||||
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for any routine run for {routine_id} to complete"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: cron_routine_fires
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -345,6 +453,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -423,6 +532,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -517,6 +627,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -625,6 +736,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -767,6 +879,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -953,6 +1066,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -1083,6 +1197,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None, // no scheduler — rejected before dispatch
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -1100,8 +1215,6 @@ mod tests {
|
||||
title: "t".to_string(),
|
||||
description: "d".to_string(),
|
||||
max_iterations: 3,
|
||||
tool_permissions: vec![],
|
||||
permission_mode: ironclaw::agent::routine::FullJobPermissionMode::Explicit,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: Duration::from_secs(0),
|
||||
@@ -1192,6 +1305,7 @@ mod tests {
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
safety,
|
||||
SandboxReadiness::DisabledByConfig,
|
||||
@@ -1250,28 +1364,27 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: inherit_owner full_job routines can use owner-gated tools
|
||||
// Test: lightweight manual routines use the owner's active extension tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_inherit_owner_uses_owner_allowlist() {
|
||||
let (backend, _tmp) = create_test_backend().await;
|
||||
async fn lightweight_manual_routine_uses_active_owner_extension_tool() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(true)).await;
|
||||
|
||||
db.set_setting(
|
||||
"default",
|
||||
ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY,
|
||||
&serde_json::json!(["owner_gate"]),
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_lightweight_trace(),
|
||||
tools_dir.as_path(),
|
||||
Some("default"),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("set owner allowlist");
|
||||
.await;
|
||||
|
||||
let routine = make_full_job_routine(
|
||||
"inherit-owner-allowed",
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
vec![],
|
||||
);
|
||||
let mut routine = make_routine("manual-owner-gate", Trigger::Manual, "Use owner_gate.");
|
||||
if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action {
|
||||
*use_tools = true;
|
||||
}
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
|
||||
let run_id = engine
|
||||
@@ -1285,20 +1398,141 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: inherit_owner full_job routines stay blocked without owner allowlist
|
||||
// Test: full_job cron routines use the owner's active extension tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_inherit_owner_blocks_without_owner_allowlist() {
|
||||
let (backend, _tmp) = create_test_backend().await;
|
||||
async fn full_job_cron_routine_uses_active_owner_extension_tool() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(false)).await;
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_trace(true),
|
||||
tools_dir.as_path(),
|
||||
Some("default"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let routine = make_full_job_routine(
|
||||
"inherit-owner-blocked",
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
vec![],
|
||||
let mut routine = make_full_job_routine("cron-owner-gate");
|
||||
routine.trigger = Trigger::Cron {
|
||||
schedule: "* * * * *".to_string(),
|
||||
timezone: None,
|
||||
};
|
||||
routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1));
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
|
||||
engine.check_cron_triggers().await;
|
||||
let run = wait_for_any_run_completion(&db, routine.id).await;
|
||||
|
||||
assert_eq!(run.status, RunStatus::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: lightweight event routines use the owner's active extension tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lightweight_event_routine_uses_active_owner_extension_tool() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_lightweight_trace(),
|
||||
tools_dir.as_path(),
|
||||
Some("default"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut routine = make_routine(
|
||||
"event-owner-gate",
|
||||
Trigger::Event {
|
||||
channel: None,
|
||||
pattern: "owner-gate".to_string(),
|
||||
},
|
||||
"Use owner_gate.",
|
||||
);
|
||||
if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action {
|
||||
*use_tools = true;
|
||||
}
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
let fired = engine
|
||||
.check_event_triggers("default", "test", "owner-gate")
|
||||
.await;
|
||||
assert_eq!(fired, 1, "expected one matching event routine");
|
||||
|
||||
let run = wait_for_any_run_completion(&db, routine.id).await;
|
||||
assert_eq!(run.status, RunStatus::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: full_job system-event routines use the owner's active extension tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_system_event_routine_uses_active_owner_extension_tool() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_trace(true),
|
||||
tools_dir.as_path(),
|
||||
Some("default"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut routine = make_full_job_routine("system-owner-gate");
|
||||
routine.trigger = Trigger::SystemEvent {
|
||||
source: "github".to_string(),
|
||||
event_type: "issue.opened".to_string(),
|
||||
filters: std::collections::HashMap::new(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
let fired = engine
|
||||
.emit_system_event(
|
||||
"github",
|
||||
"issue.opened",
|
||||
&serde_json::json!({"issue_number": 7}),
|
||||
Some("default"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(fired, 1, "expected one matching system_event routine");
|
||||
|
||||
let run = wait_for_any_run_completion(&db, routine.id).await;
|
||||
assert_eq!(run.status, RunStatus::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: autonomous runs fail loudly when an extension tool is inactive
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_blocks_without_active_owner_extension_tool() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_trace(false),
|
||||
tools_dir.as_path(),
|
||||
Some("default"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let routine = make_full_job_routine("inactive-owner-gate");
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
|
||||
let run_id = engine
|
||||
@@ -1309,27 +1543,67 @@ mod tests {
|
||||
|
||||
assert_eq!(run.status, RunStatus::Failed);
|
||||
assert_eq!(owner_gate_count(&db).await, 0);
|
||||
let failure_reason = db
|
||||
.get_agent_job_failure_reason(run.job_id.expect("linked job id"))
|
||||
.await
|
||||
.expect("load job failure reason")
|
||||
.expect("missing job failure reason");
|
||||
assert!(
|
||||
failure_reason.contains("owner_gate"),
|
||||
"expected missing-tool failure reason, got {failure_reason}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: legacy full_job routines remain explicit until updated
|
||||
// Test: extension tools activated for another owner are not inherited
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_full_job_stays_explicit_until_updated() {
|
||||
let (backend, _tmp) = create_test_backend().await;
|
||||
async fn full_job_blocks_when_extension_belongs_to_another_owner() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_trace(false),
|
||||
tools_dir.as_path(),
|
||||
Some("someone-else"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
let routine = make_full_job_routine("other-owner-gate");
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
|
||||
let run_id = engine
|
||||
.fire_manual(routine.id, None)
|
||||
.await
|
||||
.expect("fire manual");
|
||||
let run = wait_for_run_completion(&db, routine.id, run_id).await;
|
||||
|
||||
assert_eq!(run.status, RunStatus::Failed);
|
||||
assert_eq!(owner_gate_count(&db).await, 0);
|
||||
let failure_reason = db
|
||||
.get_agent_job_failure_reason(run.job_id.expect("linked job id"))
|
||||
.await
|
||||
.expect("load job failure reason")
|
||||
.expect("missing job failure reason");
|
||||
assert!(
|
||||
failure_reason.contains("owner_gate"),
|
||||
"expected owner-mismatch failure reason, got {failure_reason}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: legacy permission fields are ignored on read and removed on rewrite
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_full_job_permission_fields_are_ignored_and_removed_on_update() {
|
||||
let (backend, tmp) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend.clone();
|
||||
|
||||
db.set_setting(
|
||||
"default",
|
||||
ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY,
|
||||
&serde_json::json!(["owner_gate"]),
|
||||
)
|
||||
.await
|
||||
.expect("set owner allowlist");
|
||||
|
||||
let legacy_routine =
|
||||
make_full_job_routine("legacy-full-job", FullJobPermissionMode::Explicit, vec![]);
|
||||
let legacy_routine = make_full_job_routine("legacy-full-job");
|
||||
db.create_routine(&legacy_routine)
|
||||
.await
|
||||
.expect("create_routine");
|
||||
@@ -1342,59 +1616,77 @@ mod tests {
|
||||
"title": legacy_routine.name,
|
||||
"description": "Use the owner-gated tool when permitted.",
|
||||
"max_iterations": 3,
|
||||
"tool_permissions": [],
|
||||
"tool_permissions": ["owner_gate"],
|
||||
"permission_mode": "inherit_owner",
|
||||
})
|
||||
.to_string(),
|
||||
legacy_routine.id.to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("strip permission_mode from action_config");
|
||||
.expect("inject legacy permission fields into action_config");
|
||||
|
||||
let blocked_engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(false)).await;
|
||||
let first_run_id = blocked_engine
|
||||
.fire_manual(legacy_routine.id, None)
|
||||
let loaded = db
|
||||
.get_routine(legacy_routine.id)
|
||||
.await
|
||||
.expect("fire manual legacy routine");
|
||||
let first_run = wait_for_run_completion(&db, legacy_routine.id, first_run_id).await;
|
||||
.expect("get_routine")
|
||||
.expect("routine should still exist");
|
||||
assert!(matches!(
|
||||
loaded.action,
|
||||
RoutineAction::FullJob {
|
||||
ref title,
|
||||
ref description,
|
||||
max_iterations,
|
||||
} if title == "legacy-full-job"
|
||||
&& description == "Use the owner-gated tool when permitted."
|
||||
&& max_iterations == 3
|
||||
));
|
||||
|
||||
assert_eq!(first_run.status, RunStatus::Failed);
|
||||
assert_eq!(owner_gate_count(&db).await, 0);
|
||||
|
||||
let update_tool = RoutineUpdateTool::new(db.clone(), blocked_engine.clone());
|
||||
let tools_dir = tmp.path().join("wasm-tools");
|
||||
let engine = setup_owner_gate_engine(
|
||||
db.clone(),
|
||||
owner_gate_trace(false),
|
||||
tools_dir.as_path(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let update_tool = RoutineUpdateTool::new(db.clone(), engine);
|
||||
let update_ctx = JobContext::with_user("default", "update", "update legacy routine");
|
||||
update_tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"name": legacy_routine.name,
|
||||
"permission_mode": "inherit_owner",
|
||||
"prompt": "Updated legacy description",
|
||||
}),
|
||||
&update_ctx,
|
||||
)
|
||||
.await
|
||||
.expect("routine_update should succeed");
|
||||
|
||||
let updated = db
|
||||
.get_routine(legacy_routine.id)
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT action_config FROM routines WHERE id = ?1",
|
||||
params![legacy_routine.id.to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("get_routine")
|
||||
.expect("routine should still exist");
|
||||
assert!(matches!(
|
||||
updated.action,
|
||||
RoutineAction::FullJob {
|
||||
permission_mode: FullJobPermissionMode::InheritOwner,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let allowed_engine = setup_owner_gate_engine(db.clone(), owner_gate_trace(true)).await;
|
||||
let second_run_id = allowed_engine
|
||||
.fire_manual(legacy_routine.id, None)
|
||||
.expect("select updated action_config");
|
||||
let row = rows
|
||||
.next()
|
||||
.await
|
||||
.expect("fire manual updated routine");
|
||||
let second_run = wait_for_run_completion(&db, legacy_routine.id, second_run_id).await;
|
||||
.expect("next row")
|
||||
.expect("updated routine row");
|
||||
let action_config_raw: String = row.get(0).expect("action_config text");
|
||||
let action_config: serde_json::Value =
|
||||
serde_json::from_str(&action_config_raw).expect("parse updated action_config");
|
||||
|
||||
assert_eq!(second_run.status, RunStatus::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
assert_eq!(
|
||||
action_config,
|
||||
serde_json::json!({
|
||||
"title": "legacy-full-job",
|
||||
"description": "Updated legacy description",
|
||||
"max_iterations": 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ mod tests {
|
||||
|
||||
use chrono::Utc;
|
||||
use ironclaw::agent::routine::{
|
||||
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -266,7 +266,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routines_detail_exposes_full_job_permission_resolution() {
|
||||
async fn routines_detail_omits_legacy_full_job_permission_surface() {
|
||||
let mock = MockOpenAiServerBuilder::new()
|
||||
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
|
||||
.start()
|
||||
@@ -276,25 +276,6 @@ mod tests {
|
||||
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
|
||||
.await;
|
||||
|
||||
harness
|
||||
.db
|
||||
.set_setting(
|
||||
&harness.user_id,
|
||||
ironclaw::agent::routine::FULL_JOB_OWNER_ALLOWED_TOOLS_SETTING_KEY,
|
||||
&serde_json::json!(["shell", "http"]),
|
||||
)
|
||||
.await
|
||||
.expect("set owner allowlist");
|
||||
harness
|
||||
.db
|
||||
.set_setting(
|
||||
&harness.user_id,
|
||||
ironclaw::agent::routine::FULL_JOB_DEFAULT_PERMISSION_MODE_SETTING_KEY,
|
||||
&serde_json::json!("copy_owner"),
|
||||
)
|
||||
.await
|
||||
.expect("set owner default mode");
|
||||
|
||||
let routine = Routine {
|
||||
id: Uuid::new_v4(),
|
||||
name: "wf-full-job-permissions".to_string(),
|
||||
@@ -306,8 +287,6 @@ mod tests {
|
||||
title: "permission-detail".to_string(),
|
||||
description: "Check effective permission detail".to_string(),
|
||||
max_iterations: 3,
|
||||
tool_permissions: vec!["message".to_string()],
|
||||
permission_mode: FullJobPermissionMode::InheritOwner,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: Duration::from_secs(0),
|
||||
@@ -346,21 +325,14 @@ mod tests {
|
||||
.await
|
||||
.expect("invalid detail response");
|
||||
|
||||
assert_eq!(
|
||||
detail["full_job_permissions"]["permission_mode"].as_str(),
|
||||
Some("inherit_owner")
|
||||
assert!(
|
||||
detail.get("full_job_permissions").is_none(),
|
||||
"detail response should not expose legacy permission fields: {detail}"
|
||||
);
|
||||
assert_eq!(detail["action"]["type"].as_str(), Some("full_job"));
|
||||
assert_eq!(
|
||||
detail["full_job_permissions"]["default_permission_mode"].as_str(),
|
||||
Some("copy_owner")
|
||||
);
|
||||
assert_eq!(
|
||||
detail["full_job_permissions"]["owner_allowed_tools"],
|
||||
serde_json::json!(["shell", "http"])
|
||||
);
|
||||
assert_eq!(
|
||||
detail["full_job_permissions"]["effective_tool_permissions"],
|
||||
serde_json::json!(["shell", "http", "message"])
|
||||
detail["action"]["description"].as_str(),
|
||||
Some("Check effective permission detail")
|
||||
);
|
||||
|
||||
harness.shutdown().await;
|
||||
|
||||
@@ -591,6 +591,7 @@ impl TestRigBuilder {
|
||||
Arc::clone(ws),
|
||||
notify_tx,
|
||||
None,
|
||||
None,
|
||||
components.tools.clone(),
|
||||
components.safety.clone(),
|
||||
ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
|
||||
|
||||
Reference in New Issue
Block a user