Add operation approval policy editor
This commit is contained in:
@@ -175,6 +175,7 @@ mod tests {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -38,7 +38,8 @@ mod workspaces;
|
||||
|
||||
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
|
||||
use operation_validation::{
|
||||
validate_idempotency_policy, validate_protocol_target, validate_response_cache_policy,
|
||||
validate_approval_policy, validate_idempotency_policy, validate_protocol_target,
|
||||
validate_response_cache_policy,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -298,6 +299,7 @@ impl AdminService {
|
||||
validate_protocol_target(payload.protocol, &payload.target)?;
|
||||
validate_response_cache_policy(&payload.target, &payload.execution_config)?;
|
||||
validate_idempotency_policy(&payload.target, &payload.execution_config)?;
|
||||
validate_approval_policy(&payload.execution_config)?;
|
||||
payload.input_mapping.validate_paths()?;
|
||||
payload.output_mapping.validate_paths()?;
|
||||
Ok(())
|
||||
@@ -308,6 +310,7 @@ impl AdminService {
|
||||
validate_protocol_target(operation.protocol, &operation.target)?;
|
||||
validate_response_cache_policy(&operation.target, &operation.execution_config)?;
|
||||
validate_idempotency_policy(&operation.target, &operation.execution_config)?;
|
||||
validate_approval_policy(&operation.execution_config)?;
|
||||
operation.input_mapping.validate_paths()?;
|
||||
operation.output_mapping.validate_paths()?;
|
||||
Ok(())
|
||||
|
||||
@@ -387,6 +387,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ impl AdminService {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -105,16 +105,60 @@ pub(super) fn validate_idempotency_policy(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_approval_policy(
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(policy) = execution_config.approval_policy.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !policy.required {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if policy.ttl_seconds == 0 || policy.ttl_seconds > 300 {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"approval ttl must be between 1 and 300 seconds".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.approval_policy.ttl_seconds",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if policy.confirmation_title.trim().is_empty() {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"approval confirmation title is required".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.approval_policy.confirmation_title",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if policy.confirmation_body_template.trim().is_empty() {
|
||||
return Err(ApiError::validation_with_context(
|
||||
"approval confirmation body is required".to_owned(),
|
||||
json!({
|
||||
"field": "execution_config.approval_policy.confirmation_body_template",
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_core::{
|
||||
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, ResponseCachePolicy,
|
||||
RestTarget, Target,
|
||||
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy,
|
||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||
ResponseCachePolicy, RestTarget, Target,
|
||||
};
|
||||
|
||||
use super::{validate_idempotency_policy, validate_response_cache_policy};
|
||||
use super::{
|
||||
validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy,
|
||||
};
|
||||
|
||||
fn cacheable_execution_config() -> ExecutionConfig {
|
||||
ExecutionConfig {
|
||||
@@ -123,6 +167,7 @@ mod tests {
|
||||
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
@@ -140,6 +185,7 @@ mod tests {
|
||||
header_name: Some("Idempotency-Key".to_owned()),
|
||||
}),
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
@@ -238,4 +284,42 @@ mod tests {
|
||||
"required idempotency needs input_field or header_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_approval_policy() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
confirmation_title: "Подтвердите действие".to_owned(),
|
||||
confirmation_body_template: "Выполнить действие?".to_owned(),
|
||||
ttl_seconds: 300,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
});
|
||||
|
||||
validate_approval_policy(&config).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_approval_policy() {
|
||||
let mut config = cacheable_execution_config();
|
||||
config.approval_policy = Some(OperationApprovalPolicy {
|
||||
required: true,
|
||||
risk_level: OperationApprovalRiskLevel::Dangerous,
|
||||
confirmation_title: "".to_owned(),
|
||||
confirmation_body_template: "Выполнить действие?".to_owned(),
|
||||
ttl_seconds: 0,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
|
||||
});
|
||||
|
||||
let error = validate_approval_policy(&config).unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::error::ApiError::Validation { .. }));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"approval ttl must be between 1 and 300 seconds"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,7 @@ pub(super) fn test_operation_payload(base_url: &str, name: &str) -> OperationPay
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -483,6 +483,7 @@ pub(super) fn test_operation(base_url: &str, name: &str) -> Operation<Schema, Ma
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -1058,6 +1058,62 @@
|
||||
.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.toggle-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
.approval-toggle-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.approval-toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.approval-config-fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: rgba(13, 17, 23, 0.42);
|
||||
}
|
||||
|
||||
.approval-preview-pill {
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.approval-preview-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(47, 129, 247, 0.28);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(47, 129, 247, 0.12), rgba(35, 134, 54, 0.06)),
|
||||
var(--bg-overlay);
|
||||
}
|
||||
|
||||
.approval-preview-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.approval-preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.approval-preview-body {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
BOTTOM ACTION BAR — frosted dark glass
|
||||
══════════════════════════════════════════════════ */
|
||||
|
||||
@@ -126,6 +126,85 @@ tls:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card approval-gate-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4l5-2z"/>
|
||||
<path d="M6 8l1.4 1.4L10.5 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.approval.title">Подтверждение человеком</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.approval.subtitle">Включайте для действий, которые нельзя выполнять без явного решения пользователя.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<label class="toggle-row approval-toggle-row" for="approval-required">
|
||||
<span id="approval-required-toggle" class="toggle" aria-hidden="true"></span>
|
||||
<span class="toggle-text">
|
||||
<span class="toggle-label" data-i18n="wizard.approval.required_label">Требовать подтверждение перед выполнением</span>
|
||||
<span class="toggle-desc" data-i18n="wizard.approval.required_desc">MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.</span>
|
||||
</span>
|
||||
<input id="approval-required" type="checkbox" class="approval-toggle-input">
|
||||
</label>
|
||||
|
||||
<div id="approval-config-fields" class="approval-config-fields" hidden>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-risk-level" data-i18n="wizard.approval.risk_level">Уровень риска</label>
|
||||
<select id="approval-risk-level" class="form-select">
|
||||
<option value="normal" data-i18n="wizard.approval.risk.normal">Обычное действие</option>
|
||||
<option value="dangerous" data-i18n="wizard.approval.risk.dangerous">Опасное действие</option>
|
||||
<option value="financial" data-i18n="wizard.approval.risk.financial">Финансовое действие</option>
|
||||
<option value="irreversible" data-i18n="wizard.approval.risk.irreversible">Необратимое действие</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-ttl-seconds" data-i18n="wizard.approval.ttl">Сколько ждать подтверждение</label>
|
||||
<select id="approval-ttl-seconds" class="form-select">
|
||||
<option value="60">1 минута</option>
|
||||
<option value="180">3 минуты</option>
|
||||
<option value="300" selected>5 минут</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-title" data-i18n="wizard.approval.confirmation_title">Заголовок подтверждения</label>
|
||||
<input id="approval-title" class="form-input" type="text" autocomplete="off" placeholder="Подтвердите выполнение операции">
|
||||
<div class="form-hint" data-i18n="wizard.approval.confirmation_title_hint">Этот текст увидит внешний интерфейс подтверждения.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-body" data-i18n="wizard.approval.confirmation_body">Описание для пользователя</label>
|
||||
<textarea id="approval-body" class="form-textarea" rows="4" placeholder="Проверьте параметры операции и подтвердите выполнение."></textarea>
|
||||
<div class="form-hint" data-i18n="wizard.approval.confirmation_body_hint">Коротко объясните, что произойдет после подтверждения.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="checkbox-pill approval-preview-pill">
|
||||
<input id="approval-show-payload-preview" type="checkbox" checked>
|
||||
<span data-i18n="wizard.approval.show_payload">Показывать параметры запроса</span>
|
||||
</label>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="approval-payload-preview-mode" data-i18n="wizard.approval.payload_mode">Как показывать параметры</label>
|
||||
<select id="approval-payload-preview-mode" class="form-select">
|
||||
<option value="summary" data-i18n="wizard.approval.payload.summary">Краткое описание</option>
|
||||
<option value="masked_json" data-i18n="wizard.approval.payload.masked_json">JSON с маскированием секретов</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-preview-card">
|
||||
<div class="approval-preview-eyebrow" data-i18n="wizard.approval.preview_label">Предпросмотр для интерфейса подтверждения</div>
|
||||
<div class="approval-preview-title" id="approval-preview-title">Подтвердите выполнение операции</div>
|
||||
<div class="approval-preview-body" id="approval-preview-body">Проверьте параметры операции и подтвердите выполнение.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Проверка и публикация</span>
|
||||
<div class="section-divider-line"></div>
|
||||
|
||||
@@ -572,6 +572,27 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Execution settings',
|
||||
'wizard.step5.exec_title': 'Request execution',
|
||||
'wizard.step5.exec_subtitle': 'Timeout, retry count and authorization profile',
|
||||
'wizard.approval.title': 'Human confirmation',
|
||||
'wizard.approval.subtitle': 'Enable this for actions that must not run without an explicit user decision.',
|
||||
'wizard.approval.required_label': 'Require confirmation before execution',
|
||||
'wizard.approval.required_desc': 'The MCP client receives a pending request, and the action runs only after confirmation through a separate approval endpoint.',
|
||||
'wizard.approval.risk_level': 'Risk level',
|
||||
'wizard.approval.risk.normal': 'Normal action',
|
||||
'wizard.approval.risk.dangerous': 'Dangerous action',
|
||||
'wizard.approval.risk.financial': 'Financial action',
|
||||
'wizard.approval.risk.irreversible': 'Irreversible action',
|
||||
'wizard.approval.ttl': 'How long to wait for confirmation',
|
||||
'wizard.approval.confirmation_title': 'Confirmation title',
|
||||
'wizard.approval.confirmation_title_hint': 'This text will be shown by the external confirmation interface.',
|
||||
'wizard.approval.confirmation_body': 'Description for the user',
|
||||
'wizard.approval.confirmation_body_hint': 'Explain briefly what will happen after confirmation.',
|
||||
'wizard.approval.show_payload': 'Show request parameters',
|
||||
'wizard.approval.payload_mode': 'How to show parameters',
|
||||
'wizard.approval.payload.summary': 'Short summary',
|
||||
'wizard.approval.payload.masked_json': 'JSON with masked secrets',
|
||||
'wizard.approval.preview_label': 'Preview for confirmation UI',
|
||||
'wizard.approval.default_title': 'Confirm operation execution',
|
||||
'wizard.approval.default_body': 'Review operation parameters and confirm execution.',
|
||||
'wizard.step5.security_level_title': 'Operation security',
|
||||
'wizard.step5.community_security_note': '',
|
||||
'wizard.step5.live_title': 'Check and publish',
|
||||
@@ -1396,6 +1417,27 @@ var TRANSLATIONS = {
|
||||
'wizard.step5.execution': 'Параметры выполнения',
|
||||
'wizard.step5.exec_title': 'Выполнение запроса',
|
||||
'wizard.step5.exec_subtitle': 'Время ожидания, повторные попытки и профиль авторизации',
|
||||
'wizard.approval.title': 'Подтверждение человеком',
|
||||
'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.',
|
||||
'wizard.approval.required_label': 'Требовать подтверждение перед выполнением',
|
||||
'wizard.approval.required_desc': 'MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.',
|
||||
'wizard.approval.risk_level': 'Уровень риска',
|
||||
'wizard.approval.risk.normal': 'Обычное действие',
|
||||
'wizard.approval.risk.dangerous': 'Опасное действие',
|
||||
'wizard.approval.risk.financial': 'Финансовое действие',
|
||||
'wizard.approval.risk.irreversible': 'Необратимое действие',
|
||||
'wizard.approval.ttl': 'Сколько ждать подтверждение',
|
||||
'wizard.approval.confirmation_title': 'Заголовок подтверждения',
|
||||
'wizard.approval.confirmation_title_hint': 'Этот текст увидит внешний интерфейс подтверждения.',
|
||||
'wizard.approval.confirmation_body': 'Описание для пользователя',
|
||||
'wizard.approval.confirmation_body_hint': 'Коротко объясните, что произойдет после подтверждения.',
|
||||
'wizard.approval.show_payload': 'Показывать параметры запроса',
|
||||
'wizard.approval.payload_mode': 'Как показывать параметры',
|
||||
'wizard.approval.payload.summary': 'Краткое описание',
|
||||
'wizard.approval.payload.masked_json': 'JSON с маскированием секретов',
|
||||
'wizard.approval.preview_label': 'Предпросмотр для интерфейса подтверждения',
|
||||
'wizard.approval.default_title': 'Подтвердите выполнение операции',
|
||||
'wizard.approval.default_body': 'Проверьте параметры операции и подтвердите выполнение.',
|
||||
'wizard.step5.security_level_title': 'Защита операции',
|
||||
'wizard.step5.community_security_note': '',
|
||||
'wizard.step5.live_title': 'Проверка и публикация',
|
||||
|
||||
@@ -27,6 +27,45 @@ function buildWizardState() {
|
||||
};
|
||||
}
|
||||
|
||||
function checkedValue(id) {
|
||||
var element = document.getElementById(id);
|
||||
return !!(element && element.checked);
|
||||
}
|
||||
|
||||
function normalizeApprovalTtlSeconds(value) {
|
||||
var ttl = Number(value || 300);
|
||||
if (!Number.isFinite(ttl)) return 300;
|
||||
return Math.max(1, Math.min(300, Math.round(ttl)));
|
||||
}
|
||||
|
||||
function buildApprovalPolicy() {
|
||||
if (!checkedValue('approval-required')) return null;
|
||||
|
||||
var title = textValue('approval-title') || tKey('wizard.approval.default_title');
|
||||
var body = textValue('approval-body') || tKey('wizard.approval.default_body');
|
||||
|
||||
return {
|
||||
required: true,
|
||||
risk_level: textValue('approval-risk-level') || 'normal',
|
||||
confirmation_title: title,
|
||||
confirmation_body_template: body,
|
||||
ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')),
|
||||
show_payload_preview: checkedValue('approval-show-payload-preview'),
|
||||
payload_preview_mode: textValue('approval-payload-preview-mode') || 'summary',
|
||||
};
|
||||
}
|
||||
|
||||
function applyApprovalPolicyToExecutionConfig(config) {
|
||||
var next = config || {};
|
||||
var policy = buildApprovalPolicy();
|
||||
if (policy) {
|
||||
next.approval_policy = policy;
|
||||
} else {
|
||||
next.approval_policy = null;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectWizardPayload() {
|
||||
var name = textValue('tool-name');
|
||||
if (!name) throw new Error(tKey('wizard.error.tool_name'));
|
||||
@@ -50,7 +89,7 @@ function collectWizardPayload() {
|
||||
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
|
||||
input_mapping: buildMappingSet(inputMappingValue, 'input'),
|
||||
output_mapping: buildMappingSet(outputMappingValue, 'output'),
|
||||
execution_config: parseExecutionConfig(textValue('tool-exec-config')),
|
||||
execution_config: applyApprovalPolicyToExecutionConfig(parseExecutionConfig(textValue('tool-exec-config'))),
|
||||
tool_description: buildToolDescription(),
|
||||
wizard_state: buildWizardState(),
|
||||
};
|
||||
@@ -126,6 +165,7 @@ function bindWizardLiveActions() {
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
|
||||
window.CrankWizardMapping.initialize();
|
||||
}
|
||||
bindApprovalPolicyControls();
|
||||
bindAgentFacingPreview();
|
||||
}
|
||||
|
||||
@@ -147,6 +187,54 @@ function bindLiveAction(id, busyLabel, handler) {
|
||||
});
|
||||
}
|
||||
|
||||
function setApprovalPolicyEditor(policy) {
|
||||
var enabled = !!(policy && policy.required);
|
||||
var required = document.getElementById('approval-required');
|
||||
if (required) required.checked = enabled;
|
||||
setValue('approval-risk-level', policy && policy.risk_level ? policy.risk_level : 'normal');
|
||||
setValue('approval-ttl-seconds', policy && policy.ttl_seconds ? String(policy.ttl_seconds) : '300');
|
||||
setValue('approval-title', policy && policy.confirmation_title ? policy.confirmation_title : tKey('wizard.approval.default_title'));
|
||||
setValue('approval-body', policy && policy.confirmation_body_template ? policy.confirmation_body_template : tKey('wizard.approval.default_body'));
|
||||
var showPayload = document.getElementById('approval-show-payload-preview');
|
||||
if (showPayload) {
|
||||
showPayload.checked = !policy || policy.show_payload_preview !== false;
|
||||
}
|
||||
setValue('approval-payload-preview-mode', policy && policy.payload_preview_mode ? policy.payload_preview_mode : 'summary');
|
||||
updateApprovalPolicyUi();
|
||||
}
|
||||
|
||||
function updateApprovalPolicyUi() {
|
||||
var enabled = checkedValue('approval-required');
|
||||
var toggle = document.getElementById('approval-required-toggle');
|
||||
var fields = document.getElementById('approval-config-fields');
|
||||
if (toggle) toggle.classList.toggle('on', enabled);
|
||||
if (fields) fields.hidden = !enabled;
|
||||
|
||||
var title = textValue('approval-title') || tKey('wizard.approval.default_title');
|
||||
var body = textValue('approval-body') || tKey('wizard.approval.default_body');
|
||||
setTextContent('approval-preview-title', title);
|
||||
setTextContent('approval-preview-body', body);
|
||||
}
|
||||
|
||||
function bindApprovalPolicyControls() {
|
||||
[
|
||||
'approval-required',
|
||||
'approval-risk-level',
|
||||
'approval-ttl-seconds',
|
||||
'approval-title',
|
||||
'approval-body',
|
||||
'approval-show-payload-preview',
|
||||
'approval-payload-preview-mode',
|
||||
].forEach(function(id) {
|
||||
var element = document.getElementById(id);
|
||||
if (!element || element.dataset.approvalBound === 'true') return;
|
||||
element.dataset.approvalBound = 'true';
|
||||
element.addEventListener('input', updateApprovalPolicyUi);
|
||||
element.addEventListener('change', updateApprovalPolicyUi);
|
||||
});
|
||||
updateApprovalPolicyUi();
|
||||
}
|
||||
|
||||
async function runWizardLiveAction(button, busyLabel, handler) {
|
||||
if (!button || button.dataset.busy === 'true') {
|
||||
return;
|
||||
@@ -708,4 +796,5 @@ function copyTestResponseToOutputSample() {
|
||||
bindWizardLiveActions: bindWizardLiveActions,
|
||||
updateWizardProtocolVisibility: updateWizardProtocolVisibility,
|
||||
renderAgentFacingPreview: renderAgentFacingPreview,
|
||||
setApprovalPolicyEditor: setApprovalPolicyEditor,
|
||||
};
|
||||
|
||||
@@ -429,6 +429,13 @@ function executionConfigToEditorValue(config) {
|
||||
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function setApprovalPolicyFromSnapshot(config) {
|
||||
if (!window.CrankWizardLive || typeof window.CrankWizardLive.setApprovalPolicyEditor !== 'function') {
|
||||
return;
|
||||
}
|
||||
window.CrankWizardLive.setApprovalPolicyEditor(config && config.approval_policy ? config.approval_policy : null);
|
||||
}
|
||||
|
||||
function operationSnapshot(versionDocument) {
|
||||
if (!versionDocument) return {};
|
||||
return versionDocument.snapshot || versionDocument;
|
||||
@@ -487,6 +494,7 @@ function prefillWizardFromEdit(detail, versionDocument) {
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(snapshot.input_mapping, 'input', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
|
||||
setApprovalPolicyFromSnapshot(snapshot.execution_config || {});
|
||||
prefillWizardSamples(snapshot);
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
|
||||
window.CrankWizardMapping.renderFromEditors();
|
||||
|
||||
@@ -550,6 +550,15 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
confirmation_title: 'Подтвердите обмен валюты',
|
||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'masked_json',
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Получить историю курсов за месяц',
|
||||
@@ -765,6 +774,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
confirmation_title: 'Подтвердите обмен валюты',
|
||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'masked_json',
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Получить последний курс',
|
||||
@@ -810,6 +828,14 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
|
||||
await expect(page.locator('#approval-required')).toBeChecked();
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await expect(page.locator('#approval-config-fields')).toBeVisible();
|
||||
await expect(page.locator('#approval-risk-level')).toHaveValue('financial');
|
||||
await expect(page.locator('#approval-ttl-seconds')).toHaveValue('180');
|
||||
await expect(page.locator('#approval-title')).toHaveValue('Подтвердите обмен валюты');
|
||||
await expect(page.locator('#approval-body')).toHaveValue('Проверьте валюты и подтвердите выполнение операции.');
|
||||
await expect(page.locator('#approval-payload-preview-mode')).toHaveValue('masked_json');
|
||||
await page.locator('.btn-save-draft').click();
|
||||
await expect.poll(() => updatePayload).not.toBeNull();
|
||||
|
||||
@@ -835,6 +861,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
headers: {},
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
approval_policy: {
|
||||
required: true,
|
||||
risk_level: 'financial',
|
||||
confirmation_title: 'Подтвердите обмен валюты',
|
||||
confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.',
|
||||
ttl_seconds: 180,
|
||||
show_payload_preview: true,
|
||||
payload_preview_mode: 'masked_json',
|
||||
},
|
||||
});
|
||||
expect(updatePayload.tool_description).toEqual({
|
||||
title: 'Получить последний курс',
|
||||
|
||||
@@ -106,6 +106,7 @@ fn operation() -> RegistryOperation {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -41,9 +41,10 @@ pub mod domain {
|
||||
};
|
||||
pub use crate::operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy,
|
||||
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target,
|
||||
ToolDescription, ToolExample, WizardState,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
|
||||
OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
|
||||
OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy,
|
||||
Samples, Target, ToolDescription, ToolExample, WizardState,
|
||||
};
|
||||
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
@@ -128,9 +129,10 @@ pub use observability::{
|
||||
};
|
||||
pub use operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy,
|
||||
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target,
|
||||
ToolDescription, ToolExample, WizardState,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
|
||||
OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
|
||||
OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples,
|
||||
Target, ToolDescription, ToolExample, WizardState,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
|
||||
@@ -103,6 +103,35 @@ pub struct OperationSafetyPolicy {
|
||||
pub confirmation: Option<ConfirmationPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationApprovalRiskLevel {
|
||||
#[default]
|
||||
Normal,
|
||||
Dangerous,
|
||||
Financial,
|
||||
Irreversible,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationApprovalPayloadPreviewMode {
|
||||
#[default]
|
||||
Summary,
|
||||
MaskedJson,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OperationApprovalPolicy {
|
||||
pub required: bool,
|
||||
pub risk_level: OperationApprovalRiskLevel,
|
||||
pub confirmation_title: String,
|
||||
pub confirmation_body_template: String,
|
||||
pub ttl_seconds: u32,
|
||||
pub show_payload_preview: bool,
|
||||
pub payload_preview_mode: OperationApprovalPayloadPreviewMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExecutionConfig {
|
||||
pub timeout_ms: u64,
|
||||
@@ -115,6 +144,8 @@ pub struct ExecutionConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub safety: Option<OperationSafetyPolicy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<OperationApprovalPolicy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_profile_ref: Option<AuthProfileId>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
@@ -411,6 +442,7 @@ updated_at: 2026-03-25T08:10:00Z
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -231,6 +231,7 @@ fn default_execution_config() -> ExecutionConfig {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ fn destructive_delete_operation() -> Operation<Schema, MappingSet> {
|
||||
class: OperationSafetyClass::Destructive,
|
||||
confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }),
|
||||
}),
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -146,6 +146,7 @@ fn idempotent_post_operation() -> Operation<Schema, MappingSet> {
|
||||
header_name: Some("Idempotency-Key".to_owned()),
|
||||
}),
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
@@ -71,6 +71,7 @@ fn no_input_get_operation() -> Operation<Schema, MappingSet> {
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
approval_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user