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:
Henry Park
2026-03-19 18:32:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c4ab382522
commit cac6f4013c
14 changed files with 1247 additions and 232 deletions
+44 -1
View File
@@ -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
View File
@@ -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>,
+43 -1
View File
@@ -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';
+4
View File
@@ -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',
+4
View File
@@ -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': '最大输出长度',
+11
View File
@@ -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,