mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Use live owner tool scope for autonomous routines and jobs (#1453)
* Use live owner tool scope for autonomous runs * Address autonomous tool scope review feedback * Normalize routine context paths again
This commit is contained in:
@@ -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