Add operation approval policy editor

This commit is contained in:
github-ops
2026-06-24 10:45:09 +00:00
parent 5922aea68f
commit d83ab541d9
20 changed files with 451 additions and 11 deletions
+1
View File
@@ -175,6 +175,7 @@ mod tests {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
+4 -1
View File
@@ -38,7 +38,8 @@ mod workspaces;
use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage}; use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage};
use operation_validation::{ 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)] #[derive(Clone)]
@@ -298,6 +299,7 @@ impl AdminService {
validate_protocol_target(payload.protocol, &payload.target)?; validate_protocol_target(payload.protocol, &payload.target)?;
validate_response_cache_policy(&payload.target, &payload.execution_config)?; validate_response_cache_policy(&payload.target, &payload.execution_config)?;
validate_idempotency_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.input_mapping.validate_paths()?;
payload.output_mapping.validate_paths()?; payload.output_mapping.validate_paths()?;
Ok(()) Ok(())
@@ -308,6 +310,7 @@ impl AdminService {
validate_protocol_target(operation.protocol, &operation.target)?; validate_protocol_target(operation.protocol, &operation.target)?;
validate_response_cache_policy(&operation.target, &operation.execution_config)?; validate_response_cache_policy(&operation.target, &operation.execution_config)?;
validate_idempotency_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.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?; operation.output_mapping.validate_paths()?;
Ok(()) Ok(())
+1
View File
@@ -387,6 +387,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
+1
View File
@@ -191,6 +191,7 @@ impl AdminService {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
@@ -105,16 +105,60 @@ pub(super) fn validate_idempotency_policy(
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crank_core::{ use crank_core::{
ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, ResponseCachePolicy, ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy,
RestTarget, Target, 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 { fn cacheable_execution_config() -> ExecutionConfig {
ExecutionConfig { ExecutionConfig {
@@ -123,6 +167,7 @@ mod tests {
response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }), response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }),
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
} }
@@ -140,6 +185,7 @@ mod tests {
header_name: Some("Idempotency-Key".to_owned()), header_name: Some("Idempotency-Key".to_owned()),
}), }),
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
} }
@@ -238,4 +284,42 @@ mod tests {
"required idempotency needs input_field or header_name" "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, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
@@ -483,6 +483,7 @@ pub(super) fn test_operation(base_url: &str, name: &str) -> Operation<Schema, Ma
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
+56
View File
@@ -1058,6 +1058,62 @@
.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); } .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; } .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 BOTTOM ACTION BAR — frosted dark glass
══════════════════════════════════════════════════ */ ══════════════════════════════════════════════════ */
+79
View File
@@ -126,6 +126,85 @@ tls:
</div> </div>
</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;"> <div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Проверка и публикация</span> <span class="section-divider-label" data-i18n="wizard.step5.live_title">Проверка и публикация</span>
<div class="section-divider-line"></div> <div class="section-divider-line"></div>
+42
View File
@@ -572,6 +572,27 @@ var TRANSLATIONS = {
'wizard.step5.execution': 'Execution settings', 'wizard.step5.execution': 'Execution settings',
'wizard.step5.exec_title': 'Request execution', 'wizard.step5.exec_title': 'Request execution',
'wizard.step5.exec_subtitle': 'Timeout, retry count and authorization profile', '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.security_level_title': 'Operation security',
'wizard.step5.community_security_note': '', 'wizard.step5.community_security_note': '',
'wizard.step5.live_title': 'Check and publish', 'wizard.step5.live_title': 'Check and publish',
@@ -1396,6 +1417,27 @@ var TRANSLATIONS = {
'wizard.step5.execution': 'Параметры выполнения', 'wizard.step5.execution': 'Параметры выполнения',
'wizard.step5.exec_title': 'Выполнение запроса', 'wizard.step5.exec_title': 'Выполнение запроса',
'wizard.step5.exec_subtitle': 'Время ожидания, повторные попытки и профиль авторизации', '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.security_level_title': 'Защита операции',
'wizard.step5.community_security_note': '', 'wizard.step5.community_security_note': '',
'wizard.step5.live_title': 'Проверка и публикация', 'wizard.step5.live_title': 'Проверка и публикация',
+90 -1
View File
@@ -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() { function collectWizardPayload() {
var name = textValue('tool-name'); var name = textValue('tool-name');
if (!name) throw new Error(tKey('wizard.error.tool_name')); if (!name) throw new Error(tKey('wizard.error.tool_name'));
@@ -50,7 +89,7 @@ function collectWizardPayload() {
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []), output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
input_mapping: buildMappingSet(inputMappingValue, 'input'), input_mapping: buildMappingSet(inputMappingValue, 'input'),
output_mapping: buildMappingSet(outputMappingValue, 'output'), output_mapping: buildMappingSet(outputMappingValue, 'output'),
execution_config: parseExecutionConfig(textValue('tool-exec-config')), execution_config: applyApprovalPolicyToExecutionConfig(parseExecutionConfig(textValue('tool-exec-config'))),
tool_description: buildToolDescription(), tool_description: buildToolDescription(),
wizard_state: buildWizardState(), wizard_state: buildWizardState(),
}; };
@@ -126,6 +165,7 @@ function bindWizardLiveActions() {
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') { if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
window.CrankWizardMapping.initialize(); window.CrankWizardMapping.initialize();
} }
bindApprovalPolicyControls();
bindAgentFacingPreview(); 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) { async function runWizardLiveAction(button, busyLabel, handler) {
if (!button || button.dataset.busy === 'true') { if (!button || button.dataset.busy === 'true') {
return; return;
@@ -708,4 +796,5 @@ function copyTestResponseToOutputSample() {
bindWizardLiveActions: bindWizardLiveActions, bindWizardLiveActions: bindWizardLiveActions,
updateWizardProtocolVisibility: updateWizardProtocolVisibility, updateWizardProtocolVisibility: updateWizardProtocolVisibility,
renderAgentFacingPreview: renderAgentFacingPreview, renderAgentFacingPreview: renderAgentFacingPreview,
setApprovalPolicyEditor: setApprovalPolicyEditor,
}; };
+8
View File
@@ -429,6 +429,13 @@ function executionConfigToEditorValue(config) {
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2); 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) { function operationSnapshot(versionDocument) {
if (!versionDocument) return {}; if (!versionDocument) return {};
return versionDocument.snapshot || versionDocument; 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-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-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {})); setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
setApprovalPolicyFromSnapshot(snapshot.execution_config || {});
prefillWizardSamples(snapshot); prefillWizardSamples(snapshot);
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') { if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
window.CrankWizardMapping.renderFromEditors(); window.CrankWizardMapping.renderFromEditors();
+35
View File
@@ -550,6 +550,15 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
headers: {}, headers: {},
protocol_options: null, protocol_options: null,
streaming: 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: { tool_description: {
title: 'Получить историю курсов за месяц', title: 'Получить историю курсов за месяц',
@@ -765,6 +774,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
headers: {}, headers: {},
protocol_options: null, protocol_options: null,
streaming: 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: { tool_description: {
title: 'Получить последний курс', 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(/query\.base/);
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/); 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('#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 page.locator('.btn-save-draft').click();
await expect.poll(() => updatePayload).not.toBeNull(); await expect.poll(() => updatePayload).not.toBeNull();
@@ -835,6 +861,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
headers: {}, headers: {},
protocol_options: null, protocol_options: null,
streaming: 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({ expect(updatePayload.tool_description).toEqual({
title: 'Получить последний курс', title: 'Получить последний курс',
@@ -106,6 +106,7 @@ fn operation() -> RegistryOperation {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
+8 -6
View File
@@ -41,9 +41,10 @@ pub mod domain {
}; };
pub use crate::operation::{ pub use crate::operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy, IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
ToolDescription, ToolExample, WizardState, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy,
Samples, Target, ToolDescription, ToolExample, WizardState,
}; };
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion}; pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
@@ -128,9 +129,10 @@ pub use observability::{
}; };
pub use operation::{ pub use operation::{
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy, IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode,
OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass,
ToolDescription, ToolExample, WizardState, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples,
Target, ToolDescription, ToolExample, WizardState,
}; };
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
+32
View File
@@ -103,6 +103,35 @@ pub struct OperationSafetyPolicy {
pub confirmation: Option<ConfirmationPolicy>, 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)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ExecutionConfig { pub struct ExecutionConfig {
pub timeout_ms: u64, pub timeout_ms: u64,
@@ -115,6 +144,8 @@ pub struct ExecutionConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub safety: Option<OperationSafetyPolicy>, pub safety: Option<OperationSafetyPolicy>,
#[serde(skip_serializing_if = "Option::is_none")] #[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>, pub auth_profile_ref: Option<AuthProfileId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>, pub headers: BTreeMap<String, String>,
@@ -411,6 +442,7 @@ updated_at: 2026-03-25T08:10:00Z
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: Some(AuthProfileId::new("auth_01")), auth_profile_ref: Some(AuthProfileId::new("auth_01")),
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
+1
View File
@@ -231,6 +231,7 @@ fn default_execution_config() -> ExecutionConfig {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
} }
@@ -160,6 +160,7 @@ fn destructive_delete_operation() -> Operation<Schema, MappingSet> {
class: OperationSafetyClass::Destructive, class: OperationSafetyClass::Destructive,
confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }), confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }),
}), }),
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
@@ -146,6 +146,7 @@ fn idempotent_post_operation() -> Operation<Schema, MappingSet> {
header_name: Some("Idempotency-Key".to_owned()), header_name: Some("Idempotency-Key".to_owned()),
}), }),
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },
@@ -71,6 +71,7 @@ fn no_input_get_operation() -> Operation<Schema, MappingSet> {
response_cache: None, response_cache: None,
idempotency: None, idempotency: None,
safety: None, safety: None,
approval_policy: None,
auth_profile_ref: None, auth_profile_ref: None,
headers: BTreeMap::new(), headers: BTreeMap::new(),
}, },