mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 17:49:20 +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
@@ -10,11 +10,29 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
||||
use crate::agent::routine::{
|
||||
FullJobPermissionDefaultMode, FullJobPermissionMode, RoutineAction, Trigger,
|
||||
effective_full_job_tool_permissions, load_full_job_permission_settings, 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)> {
|
||||
@@ -113,6 +131,30 @@ 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,
|
||||
@@ -131,6 +173,7 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
+4
-159
@@ -36,7 +36,10 @@ use crate::channels::web::handlers::jobs::{
|
||||
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
||||
jobs_summary_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::routines::{routines_delete_handler, routines_toggle_handler};
|
||||
use crate::channels::web::handlers::routines::{
|
||||
routines_delete_handler, routines_detail_handler, routines_list_handler,
|
||||
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
@@ -2391,164 +2394,6 @@ async fn pairing_approve_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routines handlers ---
|
||||
|
||||
async fn routines_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
||||
|
||||
Ok(Json(RoutineListResponse { routines: items }))
|
||||
}
|
||||
|
||||
async fn routines_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = routines.len() as u64;
|
||||
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
|
||||
let disabled = total - enabled;
|
||||
let failing = routines
|
||||
.iter()
|
||||
.filter(|r| r.consecutive_failures > 0)
|
||||
.count() as u64;
|
||||
|
||||
let today_start = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|dt| dt.and_utc());
|
||||
let runs_today = if let Some(start) = today_start {
|
||||
routines
|
||||
.iter()
|
||||
.filter(|r| r.last_run_at.is_some_and(|ts| ts >= start))
|
||||
.count() as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(Json(RoutineSummaryResponse {
|
||||
total,
|
||||
enabled,
|
||||
disabled,
|
||||
failing,
|
||||
runs_today,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn routines_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routine_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||
|
||||
let routine = store
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||
|
||||
let runs = store
|
||||
.list_routine_runs(routine_id, 20)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let recent_runs: Vec<RoutineRunInfo> = runs
|
||||
.iter()
|
||||
.map(|run| RoutineRunInfo {
|
||||
id: run.id,
|
||||
trigger_type: run.trigger_type.clone(),
|
||||
started_at: run.started_at.to_rfc3339(),
|
||||
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
let routine_info = RoutineInfo::from_routine(&routine);
|
||||
|
||||
Ok(Json(RoutineDetailResponse {
|
||||
id: routine.id,
|
||||
name: routine.name.clone(),
|
||||
description: routine.description.clone(),
|
||||
enabled: routine.enabled,
|
||||
trigger_type: routine_info.trigger_type,
|
||||
trigger_raw: routine_info.trigger_raw,
|
||||
trigger_summary: routine_info.trigger_summary,
|
||||
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
|
||||
action: serde_json::to_value(&routine.action).unwrap_or_default(),
|
||||
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),
|
||||
notify: serde_json::to_value(&routine.notify).unwrap_or_default(),
|
||||
last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()),
|
||||
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
|
||||
run_count: routine.run_count,
|
||||
consecutive_failures: routine.consecutive_failures,
|
||||
created_at: routine.created_at.to_rfc3339(),
|
||||
recent_runs,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn routines_trigger_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let engine = {
|
||||
let guard = state.routine_engine.read().await;
|
||||
guard.as_ref().cloned().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Routine engine not available".to_string(),
|
||||
))?
|
||||
};
|
||||
|
||||
let routine_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||
|
||||
let run_id = engine
|
||||
.fire_manual(routine_id, Some(&state.user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let status = match &e {
|
||||
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
|
||||
crate::error::RoutineError::Disabled { .. }
|
||||
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "triggered",
|
||||
"routine_id": routine_id,
|
||||
"run_id": run_id,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn routines_runs_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
|
||||
@@ -3855,6 +3855,17 @@ function renderRoutineDetail(routine) {
|
||||
}
|
||||
|
||||
// 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>';
|
||||
|
||||
@@ -4689,6 +4700,10 @@ 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' },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4873,7 +4888,14 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
inputWrap.style.gap = '8px';
|
||||
|
||||
var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
|
||||
var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||
function formatSettingValue(raw) {
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
if (raw === null || raw === undefined) return '';
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
var activeValueText = formatSettingValue(activeValue);
|
||||
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||
|
||||
if (def.type === 'boolean') {
|
||||
var boolSel = document.createElement('select');
|
||||
@@ -4945,6 +4967,26 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
};
|
||||
})(def.key, numInp));
|
||||
inputWrap.appendChild(numInp);
|
||||
} else if (def.type === 'list') {
|
||||
var listInp = document.createElement('input');
|
||||
listInp.type = 'text';
|
||||
listInp.className = 'settings-input';
|
||||
listInp.setAttribute('aria-label', ariaLabel);
|
||||
var listValue = '';
|
||||
if (Array.isArray(value)) listValue = value.join(', ');
|
||||
else if (typeof value === 'string') listValue = value;
|
||||
listInp.value = listValue;
|
||||
if (!listValue) listInp.placeholder = placeholderText;
|
||||
listInp.addEventListener('change', (function(k, el) {
|
||||
return function() {
|
||||
if (el.value.trim() === '') return saveSetting(k, null);
|
||||
var items = el.value.split(/[\n,]/).map(function(item) {
|
||||
return item.trim();
|
||||
}).filter(Boolean);
|
||||
saveSetting(k, items);
|
||||
};
|
||||
})(def.key, listInp));
|
||||
inputWrap.appendChild(listInp);
|
||||
} else {
|
||||
var textInp = document.createElement('input');
|
||||
textInp.type = 'text';
|
||||
|
||||
@@ -475,6 +475,10 @@ 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',
|
||||
|
||||
@@ -474,6 +474,10 @@ I18n.register('zh-CN', {
|
||||
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
|
||||
'cfg.routines_cooldown.label': '默认冷却时间',
|
||||
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
|
||||
'cfg.routines_full_job_default_mode.label': '完整任务默认权限模式',
|
||||
'cfg.routines_full_job_default_mode.desc': '新建 full_job 定时任务的默认权限行为。未设置时使用 inherit_owner。',
|
||||
'cfg.routines_full_job_owner_tools.label': '完整任务所有者允许工具',
|
||||
'cfg.routines_full_job_owner_tools.desc': '逗号分隔的工具名列表,full_job 定时任务可在运行时继承这些工具权限。',
|
||||
|
||||
// 安全设置
|
||||
'cfg.safety_max_output.label': '最大输出长度',
|
||||
|
||||
@@ -884,9 +884,20 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user