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:
Henry Park
2026-03-23 20:27:41 -07:00
committed by GitHub
23 changed files with 944 additions and 967 deletions
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+1 -44
View File
@@ -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,
}))
}
-16
View File
@@ -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' },
]
},
{
-4
View File
@@ -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',
-4
View File
@@ -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': '最大输出长度',
-11
View File
@@ -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,
+3
View File
@@ -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,
+31
View File
@@ -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>,
+210
View File
@@ -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
View File
@@ -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(&params)?;
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(
&params,
"execution",
"permission_mode",
&["permission_mode"],
))?;
let requested_tool_permissions = optional_string_array_field(
&params,
"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(&params).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
));
}
}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1 -4
View File
@@ -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),
+3 -20
View File
@@ -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
View File
@@ -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,
})
);
}
}
+8 -36
View File
@@ -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;
+1
View File
@@ -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