mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
Add owner-scoped permissions for full-job routines (#1440)
* docs: add comments explaining CLI_ENABLED=false in service templates (#990) Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd) to prevent blocking on stdin when running as a background service. Closes #990 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Add owner-scoped full-job routine permissions * Address PR review feedback * Fix owner gate test timing --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c4ab382522
commit
cac6f4013c
@@ -15,7 +15,8 @@ mod tests {
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::agent::routine::{
|
||||
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
||||
FullJobPermissionMode, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus,
|
||||
Trigger,
|
||||
};
|
||||
use ironclaw::context::{JobContext, JobState};
|
||||
use ironclaw::db::Database;
|
||||
@@ -46,6 +47,7 @@ mod tests {
|
||||
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::{RoutineAction, Trigger};
|
||||
use ironclaw::agent::routine::{FullJobPermissionMode, RoutineAction, Trigger};
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::LlmTrace;
|
||||
@@ -359,10 +359,12 @@ mod tests {
|
||||
RoutineAction::FullJob {
|
||||
description,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
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:?}"),
|
||||
}
|
||||
@@ -413,6 +415,7 @@ mod tests {
|
||||
RoutineAction::FullJob {
|
||||
description,
|
||||
tool_permissions,
|
||||
permission_mode,
|
||||
..
|
||||
} => {
|
||||
assert!(description.contains("Prepare the morning digest"));
|
||||
@@ -420,6 +423,7 @@ mod tests {
|
||||
tool_permissions,
|
||||
&vec!["message".to_string(), "http".to_string()]
|
||||
);
|
||||
assert_eq!(permission_mode, &FullJobPermissionMode::InheritOwner);
|
||||
}
|
||||
other => panic!("expected full_job action, got {other:?}"),
|
||||
}
|
||||
|
||||
+371
-12
@@ -12,35 +12,107 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use libsql::params;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun,
|
||||
RunStatus, Trigger,
|
||||
};
|
||||
use ironclaw::agent::routine_engine::RoutineEngine;
|
||||
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
|
||||
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner, Scheduler};
|
||||
use ironclaw::channels::IncomingMessage;
|
||||
use ironclaw::config::{RoutineConfig, SafetyConfig};
|
||||
use ironclaw::db::Database;
|
||||
use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig};
|
||||
use ironclaw::context::{ContextManager, JobContext};
|
||||
use ironclaw::db::{Database, libsql::LibSqlBackend};
|
||||
use ironclaw::hooks::HookRegistry;
|
||||
use ironclaw::llm::LlmProvider;
|
||||
use ironclaw::safety::SafetyLayer;
|
||||
use ironclaw::tools::ToolRegistry;
|
||||
use ironclaw::tools::builtin::routine::RoutineUpdateTool;
|
||||
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry};
|
||||
use ironclaw::workspace::Workspace;
|
||||
use ironclaw::workspace::hygiene::HygieneConfig;
|
||||
|
||||
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep};
|
||||
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep, TraceToolCall};
|
||||
|
||||
const OWNER_GATE_COUNT_SETTING_KEY: &str = "tests.owner_gate_count";
|
||||
|
||||
struct OwnerGateTool {
|
||||
store: Arc<dyn Database>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for OwnerGateTool {
|
||||
fn name(&self) -> &str {
|
||||
"owner_gate"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Test-only tool gated by owner full_job permissions"
|
||||
}
|
||||
|
||||
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> {
|
||||
let start = std::time::Instant::now();
|
||||
let current = self
|
||||
.store
|
||||
.get_setting(&ctx.user_id, OWNER_GATE_COUNT_SETTING_KEY)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to read owner gate count: {e}"))
|
||||
})?
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0);
|
||||
self.store
|
||||
.set_setting(
|
||||
&ctx.user_id,
|
||||
OWNER_GATE_COUNT_SETTING_KEY,
|
||||
&serde_json::json!(current + 1),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to persist owner gate count: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::text("owner gate executed", start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Always
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a temp libSQL database with migrations applied.
|
||||
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
let (backend, temp_dir) = create_test_backend().await;
|
||||
let db: Arc<dyn Database> = backend;
|
||||
(db, temp_dir)
|
||||
}
|
||||
|
||||
async fn create_test_backend() -> (Arc<LibSqlBackend>, tempfile::TempDir) {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("LibSqlBackend");
|
||||
let backend = Arc::new(
|
||||
LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("LibSqlBackend"),
|
||||
);
|
||||
backend.run_migrations().await.expect("migrations");
|
||||
let db: Arc<dyn Database> = Arc::new(backend);
|
||||
(db, temp_dir)
|
||||
(backend, temp_dir)
|
||||
}
|
||||
|
||||
/// Create a workspace backed by the test database.
|
||||
@@ -93,6 +165,143 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_full_job_routine(
|
||||
name: &str,
|
||||
permission_mode: FullJobPermissionMode,
|
||||
tool_permissions: Vec<String>,
|
||||
) -> Routine {
|
||||
Routine {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
description: format!("Full-job test routine: {name}"),
|
||||
user_id: "default".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Manual,
|
||||
action: RoutineAction::FullJob {
|
||||
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),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: NotifyConfig::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_gate_trace(include_completion: bool) -> LlmTrace {
|
||||
let mut steps = 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![],
|
||||
}];
|
||||
if include_completion {
|
||||
// The worker first calls `select_tools()`, then falls back to
|
||||
// `respond_with_tools()` when no tool calls are returned. Both
|
||||
// methods consume a trace step, so the successful completion path
|
||||
// needs two text responses after the tool call.
|
||||
for _ in 0..2 {
|
||||
steps.push(TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "I have completed the task.".to_string(),
|
||||
input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
},
|
||||
expected_tool_results: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
LlmTrace::single_turn("test-owner-gate", "run owner gate", steps)
|
||||
}
|
||||
|
||||
async fn setup_owner_gate_engine(db: Arc<dyn Database>, trace: LlmTrace) -> 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;
|
||||
|
||||
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 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()),
|
||||
));
|
||||
|
||||
Arc::new(RoutineEngine::new(
|
||||
RoutineConfig::default(),
|
||||
db,
|
||||
llm,
|
||||
ws,
|
||||
notify_tx,
|
||||
Some(scheduler),
|
||||
registry,
|
||||
safety,
|
||||
))
|
||||
}
|
||||
|
||||
async fn owner_gate_count(db: &Arc<dyn Database>) -> i64 {
|
||||
db.get_setting("default", OWNER_GATE_COUNT_SETTING_KEY)
|
||||
.await
|
||||
.expect("get owner gate count")
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn wait_for_run_completion(
|
||||
db: &Arc<dyn Database>,
|
||||
routine_id: Uuid,
|
||||
run_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.id == run_id)
|
||||
&& run.status != RunStatus::Running
|
||||
{
|
||||
return run;
|
||||
}
|
||||
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for routine run {run_id} to complete"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: cron_routine_fires
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -884,6 +1093,7 @@ mod tests {
|
||||
description: "d".to_string(),
|
||||
max_iterations: 3,
|
||||
tool_permissions: vec![],
|
||||
permission_mode: ironclaw::agent::routine::FullJobPermissionMode::Explicit,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: Duration::from_secs(0),
|
||||
@@ -1029,4 +1239,153 @@ mod tests {
|
||||
"cron routine should fire after global slot is released"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: inherit_owner full_job routines can use owner-gated tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_inherit_owner_uses_owner_allowlist() {
|
||||
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"]),
|
||||
)
|
||||
.await
|
||||
.expect("set owner allowlist");
|
||||
|
||||
let routine = make_full_job_routine(
|
||||
"inherit-owner-allowed",
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
vec![],
|
||||
);
|
||||
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::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: inherit_owner full_job routines stay blocked without owner allowlist
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_job_inherit_owner_blocks_without_owner_allowlist() {
|
||||
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 routine = make_full_job_routine(
|
||||
"inherit-owner-blocked",
|
||||
FullJobPermissionMode::InheritOwner,
|
||||
vec![],
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: legacy full_job routines remain explicit until updated
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_full_job_stays_explicit_until_updated() {
|
||||
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![]);
|
||||
db.create_routine(&legacy_routine)
|
||||
.await
|
||||
.expect("create_routine");
|
||||
|
||||
let conn = backend.connect().await.expect("connect");
|
||||
conn.execute(
|
||||
"UPDATE routines SET action_config = ?1 WHERE id = ?2",
|
||||
params![
|
||||
serde_json::json!({
|
||||
"title": legacy_routine.name,
|
||||
"description": "Use the owner-gated tool when permitted.",
|
||||
"max_iterations": 3,
|
||||
"tool_permissions": [],
|
||||
})
|
||||
.to_string(),
|
||||
legacy_routine.id.to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("strip permission_mode from 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)
|
||||
.await
|
||||
.expect("fire manual legacy routine");
|
||||
let first_run = wait_for_run_completion(&db, legacy_routine.id, first_run_id).await;
|
||||
|
||||
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 update_ctx = JobContext::with_user("default", "update", "update legacy routine");
|
||||
update_tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"name": legacy_routine.name,
|
||||
"permission_mode": "inherit_owner",
|
||||
}),
|
||||
&update_ctx,
|
||||
)
|
||||
.await
|
||||
.expect("routine_update should succeed");
|
||||
|
||||
let updated = db
|
||||
.get_routine(legacy_routine.id)
|
||||
.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)
|
||||
.await
|
||||
.expect("fire manual updated routine");
|
||||
let second_run = wait_for_run_completion(&db, legacy_routine.id, second_run_id).await;
|
||||
|
||||
assert_eq!(second_run.status, RunStatus::Ok);
|
||||
assert_eq!(owner_gate_count(&db).await, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ mod support;
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use ironclaw::agent::routine::{
|
||||
FullJobPermissionMode, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
|
||||
@@ -260,4 +264,106 @@ mod tests {
|
||||
harness.shutdown().await;
|
||||
mock.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routines_detail_exposes_full_job_permission_resolution() {
|
||||
let mock = MockOpenAiServerBuilder::new()
|
||||
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let harness =
|
||||
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
|
||||
.await;
|
||||
|
||||
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(),
|
||||
description: "Permission detail regression test".to_string(),
|
||||
user_id: harness.user_id.clone(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Manual,
|
||||
action: RoutineAction::FullJob {
|
||||
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),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: NotifyConfig::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
harness
|
||||
.db
|
||||
.create_routine(&routine)
|
||||
.await
|
||||
.expect("create routine");
|
||||
|
||||
let detail = harness
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/api/routines/{}",
|
||||
harness.base_url(),
|
||||
routine.id
|
||||
))
|
||||
.bearer_auth(&harness.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("detail request failed")
|
||||
.error_for_status()
|
||||
.expect("detail non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid detail response");
|
||||
|
||||
assert_eq!(
|
||||
detail["full_job_permissions"]["permission_mode"].as_str(),
|
||||
Some("inherit_owner")
|
||||
);
|
||||
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"])
|
||||
);
|
||||
|
||||
harness.shutdown().await;
|
||||
mock.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user